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
20 changes: 20 additions & 0 deletions .changeset/great-eyes-brake.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions packages/admin/src/components/ContentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -265,6 +268,8 @@ export function ContentList({
onDateFilterChange,
bylineFilter = EMPTY_BYLINE_FILTER,
onBylineFilterChange,
termFilter,
onTermFilterChange,
onBulkPublish,
onBulkUnpublish,
onBulkDelete,
Expand Down Expand Up @@ -479,6 +484,9 @@ export function ContentList({
onDateFilterChange={onDateFilterChange}
bylineFilter={bylineFilter}
onBylineFilterChange={onBylineFilterChange}
collection={collection}
termFilter={termFilter}
onTermFilterChange={onTermFilterChange}
locale={activeLocale ?? undefined}
/>
)}
Expand Down Expand Up @@ -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;
}
Expand All @@ -847,6 +859,9 @@ function FilterBar({
onDateFilterChange,
bylineFilter,
onBylineFilterChange,
collection,
termFilter,
onTermFilterChange,
locale,
}: FilterBarProps) {
const { t } = useLingui();
Expand Down Expand Up @@ -933,6 +948,15 @@ function FilterBar({
<BylineFilter value={bylineFilter} onChange={onBylineFilterChange} locale={locale} />
)}

{onTermFilterChange && (
<TermFilters
collection={collection}
value={termFilter ?? {}}
onChange={onTermFilterChange}
locale={locale}
/>
)}

{showDateFilter && (
<>
<Select
Expand Down
128 changes: 128 additions & 0 deletions packages/admin/src/components/TermFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { Select } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import * as React from "react";

import { fetchTaxonomyDefs, fetchTerms, type TaxonomyTerm } from "../lib/api";

/**
* Taxonomy-term filters for the content list, keyed by taxonomy name.
*
* One key per taxonomy the user has narrowed. A key is present only while it
* has a selection: an empty array means "match nothing" to the server, which is
* not what an untouched control means.
*/
export type TermFilterState = Record<string, string[]>;

export const EMPTY_TERM_FILTER: TermFilterState = {};

/** Depth-first flatten so a hierarchical taxonomy reads as an indented list. */
function flatten(terms: TaxonomyTerm[], depth = 0): Array<{ term: TaxonomyTerm; depth: number }> {
return terms.flatMap((term) => [{ term, depth }, ...flatten(term.children ?? [], depth + 1)]);
}

/**
* A dropdown per taxonomy applied to this collection, beside the status,
* author and byline filters.
*
* Renders nothing when the collection has no taxonomies, so collections that
* do not use them are unchanged.
*/
export function TermFilters({
collection,
value,
onChange,
locale,
}: {
collection: string;
value: TermFilterState;
onChange: (next: TermFilterState) => void;
locale?: string;
}) {
const { t } = useLingui();

const { data: defs } = useQuery({
queryKey: ["taxonomy-defs", locale],
queryFn: () => fetchTaxonomyDefs({ locale }),
placeholderData: keepPreviousData,
});

const applicable = React.useMemo(
() => (defs ?? []).filter((def) => def.collections.includes(collection)),
[defs, collection],
);

if (applicable.length === 0) return null;

return (
<>
{applicable.map((def) => (
<TermSelect
key={def.name}
name={def.name}
label={def.label}
locale={locale}
selected={value[def.name]?.[0] ?? ""}
onSelect={(slug) => {
const next = { ...value };
// Dropping the key rather than storing an empty array keeps
// "no selection" distinct from "match nothing".
if (slug) next[def.name] = [slug];
else delete next[def.name];
onChange(next);
}}
allLabel={t`All ${def.label.toLowerCase()}`}
ariaLabel={t`Filter by ${def.label.toLowerCase()}`}
/>
))}
</>
);
}

function TermSelect({
name,
locale,
selected,
onSelect,
allLabel,
ariaLabel,
}: {
name: string;
label: string;
locale?: string;
selected: string;
onSelect: (slug: string) => void;
allLabel: string;
ariaLabel: string;
}) {
// Terms load per taxonomy and only for taxonomies actually on screen, so a
// site with several does not pay for all of them at once.
const { data: terms } = useQuery({
queryKey: ["taxonomy-terms", name, locale],
queryFn: () => fetchTerms(name, { locale }),
placeholderData: keepPreviousData,
});

const options = React.useMemo(() => flatten(terms ?? []), [terms]);
if (options.length === 0) return null;

return (
<Select
size="sm"
aria-label={ariaLabel}
value={selected}
onValueChange={(v) => onSelect(v ?? "")}
items={{
"": allLabel,
...Object.fromEntries(options.map(({ term }) => [term.slug, term.label])),
}}
>
<Select.Option value="">{allLabel}</Select.Option>
{options.map(({ term, depth }) => (
<Select.Option key={term.id} value={term.slug}>
{depth > 0 ? `${"  ".repeat(depth)}${term.label}` : term.label}
</Select.Option>
))}
</Select>
);
}
11 changes: 11 additions & 0 deletions packages/admin/src/lib/api/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>;
},
): Promise<FindManyResult<ContentItem>> {
const params = new URLSearchParams();
Expand All @@ -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)) {
Expand Down
15 changes: 15 additions & 0 deletions packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -387,6 +388,16 @@ function ContentListPage() {
const [authorFilter, setAuthorFilter] = React.useState("");
const [dateFilter, setDateFilter] = React.useState<ContentDateFilter>(EMPTY_DATE_FILTER);
const [bylineFilter, setBylineFilter] = React.useState<BylineFilterState>(EMPTY_BYLINE_FILTER);
const [termFilter, setTermFilter] = React.useState<TermFilterState>(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.
Expand Down Expand Up @@ -433,6 +444,7 @@ function ContentListPage() {
author: authorFilter,
date: dateApiParams,
byline: bylineApiParams,
terms: termApiParams,
},
],
queryFn: ({ pageParam }) =>
Expand All @@ -447,6 +459,7 @@ function ContentListPage() {
authorId: authorFilter || undefined,
...dateApiParams,
...bylineApiParams,
...termApiParams,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
Expand Down Expand Up @@ -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)}
Expand Down
40 changes: 39 additions & 1 deletion packages/core/src/api/handlers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -536,6 +536,7 @@ export async function handleContentList(
bylinesNone?: boolean;
includeInferredBylines?: boolean;
fieldFilters?: ContentFieldFilters;
termFilters?: ContentTermFilters;
},
): Promise<ApiResult<ContentListResponse>> {
try {
Expand All @@ -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;

Expand Down
Loading
Loading