Skip to content
Merged
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/tidy-pandas-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fixes the content editor recomputing taxonomy term usage counts every time it opens. The editor's taxonomy picker never shows counts, but it shares the terms endpoint with the Taxonomies settings page, which does — so opening an entry aggregated the whole content–term assignment table once per applicable taxonomy. `GET /_emdash/api/taxonomies/:name/terms` now takes an `includeCounts` query param (default `true`, so existing callers are unaffected); pass `includeCounts=false` to skip the aggregate, and `count` is then omitted from each term in the response.
4 changes: 3 additions & 1 deletion packages/admin/src/components/TaxonomyManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -749,8 +749,10 @@ export function TaxonomyManager({ taxonomyName }: TaxonomyManagerProps) {
queryFn: () => fetchTaxonomyDef(taxonomyName),
});

// The count mode belongs in the key: the editor's taxonomy picker reads the
// same endpoint without counts, and this page renders them.
const { data: terms = [], isLoading: termsLoading } = useQuery({
queryKey: ["taxonomy-terms", taxonomyName, activeLocale],
queryKey: ["taxonomy-terms", taxonomyName, activeLocale, { includeCounts: true }],
queryFn: () => fetchTerms(taxonomyName, { locale: activeLocale }),
});

Expand Down
11 changes: 8 additions & 3 deletions packages/admin/src/components/TaxonomySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,13 @@ export function useHasApplicableTaxonomies(collection: string): boolean {

/**
* Fetch terms for a taxonomy, scoped to the entry's locale so only the matching
* translation variants are offered.
* translation variants are offered. The picker shows no usage counts, so it
* opts out of the per-collection count aggregate the endpoint runs by default.
*/
async function fetchTerms(taxonomyName: string, locale?: string): Promise<TaxonomyTerm[]> {
const res = await apiFetch(withLocale(`/_emdash/api/taxonomies/${taxonomyName}/terms`, locale));
const res = await apiFetch(
withLocale(`/_emdash/api/taxonomies/${taxonomyName}/terms?includeCounts=false`, locale),
Comment thread
MA2153 marked this conversation as resolved.
);
const data = await parseApiResponse<{ terms: TaxonomyTerm[] }>(
res,
i18n._(msg`Failed to fetch terms`),
Expand Down Expand Up @@ -334,8 +337,10 @@ function TaxonomySection({
const [newCategoryLabel, setNewCategoryLabel] = React.useState("");
const [showCategoryInput, setShowCategoryInput] = React.useState(false);

// The count mode belongs in the key: the Taxonomies settings page reads the
// same endpoint with counts and must not be served this count-free list.
const { data: terms = EMPTY_TERMS } = useQuery({
queryKey: ["taxonomy-terms", taxonomy.name, entryLocale],
queryKey: ["taxonomy-terms", taxonomy.name, entryLocale, { includeCounts: false }],
queryFn: () => fetchTerms(taxonomy.name, entryLocale),
});

Expand Down
9 changes: 5 additions & 4 deletions packages/admin/tests/components/TaxonomySidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,21 +86,22 @@ function mockApiFetch({
} = {}) {
vi.mocked(apiFetch).mockImplementation((url: string | URL | Request, init?: RequestInit) => {
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
const path = new URL(urlString, "http://localhost").pathname;
const method = init?.method ?? "GET";

if (method === "GET" && urlString === "/_emdash/api/taxonomies") {
if (method === "GET" && path === "/_emdash/api/taxonomies") {
return dataResponse({ taxonomies });
}

if (method === "GET" && urlString === "/_emdash/api/taxonomies/tags/terms") {
if (method === "GET" && path === "/_emdash/api/taxonomies/tags/terms") {
return dataResponse({ terms });
}

if (method === "GET" && urlString === "/_emdash/api/taxonomies/categories/terms") {
if (method === "GET" && path === "/_emdash/api/taxonomies/categories/terms") {
return dataResponse({ terms });
}

if (method === "GET" && urlString === "/_emdash/api/content/products/entry_1/terms/tags") {
if (method === "GET" && path === "/_emdash/api/content/products/entry_1/terms/tags") {
return dataResponse({ terms: entryTerms });
}

Expand Down
113 changes: 113 additions & 0 deletions packages/admin/tests/components/taxonomy-term-cache.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Toasty } from "@cloudflare/kumo";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";

import { TaxonomyManager } from "../../src/components/TaxonomyManager";
import { TaxonomySidebar } from "../../src/components/TaxonomySidebar";
import { render } from "../utils/render.tsx";

vi.mock("../../src/lib/api/client.js", async () => {
const actual = await vi.importActual("../../src/lib/api/client.js");
return {
...actual,
apiFetch: vi.fn(),
};
});

import { apiFetch } from "../../src/lib/api/client.js";

const categoriesTaxonomy = {
id: "tax_categories",
name: "categories",
label: "Categories",
labelSingular: "Category",
hierarchical: true,
collections: ["posts"],
};

const terms = [
{ id: "1", name: "tech", slug: "tech", label: "Technology", parentId: null, children: [] },
];

function dataResponse(data: unknown) {
return Promise.resolve(
new Response(JSON.stringify({ data }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}

/** Mirrors the endpoint: counts unless the caller opts out. */
function mockApiFetch() {
vi.mocked(apiFetch).mockImplementation((url: string | URL | Request, init?: RequestInit) => {
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
const { pathname, searchParams } = new URL(urlString, "http://localhost");
const method = init?.method ?? "GET";

if (method === "GET" && pathname === "/_emdash/api/taxonomies") {
return dataResponse({ taxonomies: [categoriesTaxonomy] });
}

if (method === "GET" && pathname === "/_emdash/api/taxonomies/categories/terms") {
const withCounts = searchParams.get("includeCounts") !== "false";
return dataResponse({
terms: terms.map((term) => (withCounts ? { ...term, count: 5 } : term)),
});
}

return dataResponse({});
});
}

function makeWrapper() {
// staleTime mirrors App.tsx: inside that window a mounting consumer is served
// the cached list without refetching.
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: 1000 * 60 },
mutations: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<Toasty>{children}</Toasty>
</QueryClientProvider>
);
};
}

/** The editor sidebar renders first; the settings page mounts afterwards, as it
* would when the user navigates to it in the same SPA session. */
function EditorThenSettings() {
const [settingsOpen, setSettingsOpen] = React.useState(false);
return (
<>
<TaxonomySidebar collection="posts" />
<button type="button" onClick={() => setSettingsOpen(true)}>
Open settings
</button>
{settingsOpen ? <TaxonomyManager taxonomyName="categories" /> : null}
</>
);
}

describe("taxonomy term cache", () => {
beforeEach(() => {
vi.clearAllMocks();
mockApiFetch();
});

it("shows real counts in the manager after the editor cached a count-free list", async () => {
const screen = await render(<EditorThenSettings />, { wrapper: makeWrapper() });

await expect.element(screen.getByText("Technology")).toBeInTheDocument();

await screen.getByRole("button", { name: "Open settings" }).click();

await expect.element(screen.getByRole("heading", { name: "Categories" })).toBeInTheDocument();
await expect.element(screen.getByText("5", { exact: true })).toBeInTheDocument();
});
});
16 changes: 8 additions & 8 deletions packages/core/src/api/handlers/taxonomies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ export interface TermData {
}

export interface TermWithCount extends TermData {
count: number;
/** Absent when the caller opted out of counts (`includeCounts: false`). */
count?: number;
children: TermWithCount[];
}

Expand Down Expand Up @@ -384,7 +385,7 @@ export async function handleTaxonomyDefTranslations(
export async function handleTermList(
db: Kysely<Database>,
taxonomyName: string,
options: { locale?: string } = {},
options: { locale?: string; includeCounts?: boolean } = {},
): Promise<ApiResult<TermListResponse>> {
try {
// Definitions are per-locale but terms aren't bound to the def's locale —
Expand All @@ -401,11 +402,10 @@ export async function handleTermList(
// taxonomy's declared collections — one query for the whole list.
// content_taxonomies.taxonomy_id stores the translation_group, so we
// look up by group and map back to each term's id.
const countsByGroup = await fetchVisibleTermCounts(
db,
taxonomyName,
defCollections(lookup.def),
);
const includeCounts = options.includeCounts ?? true;
const countsByGroup = includeCounts
? await fetchVisibleTermCounts(db, taxonomyName, defCollections(lookup.def))
: undefined;

const termData: TermWithCount[] = terms.map((term) => ({
id: term.id,
Expand All @@ -415,7 +415,7 @@ export async function handleTermList(
parentId: term.parentId,
description: typeof term.data?.description === "string" ? term.data.description : undefined,
children: [],
count: countsByGroup.get(term.translationGroup ?? term.id) ?? 0,
...(countsByGroup && { count: countsByGroup.get(term.translationGroup ?? term.id) ?? 0 }),
locale: term.locale,
translationGroup: term.translationGroup,
}));
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/api/openapi/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ import {
createTermBody,
taxonomyListResponseSchema,
termGetResponseSchema,
termListQuery,
termListResponseSchema,
termResponseSchema,
updateTermBody,
Expand Down Expand Up @@ -1327,6 +1328,7 @@ const taxonomyPaths = {
tags: ["Taxonomies"],
requestParams: {
path: z.object({ name: z.string().meta({ description: "Taxonomy name" }) }),
query: termListQuery,
},
responses: {
"200": {
Expand Down
17 changes: 16 additions & 1 deletion packages/core/src/api/schemas/taxonomies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,21 @@ export const updateTermBody = z
})
.meta({ id: "UpdateTermBody" });

export const termListQuery = z
.object({
locale: localeCode.optional(),
includeCounts: z
.enum(["true", "false"])
.transform((v) => v === "true")
.optional()
.default(true)
.meta({
description:
"Include each term's visible-usage count. Pass false to skip the aggregate; `count` is then absent from every term.",
}),
})
.meta({ id: "TermListQuery" });

// ---------------------------------------------------------------------------
// Taxonomies: Response schemas
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -125,7 +140,7 @@ export const termWithCountSchema: z.ZodType = z
label: z.string(),
parentId: z.string().nullable(),
description: z.string().optional(),
count: z.number().int(),
count: z.number().int().optional(),
children: z.array(z.lazy(() => termWithCountSchema)),
locale: z.string(),
translationGroup: z.string().nullable(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { requirePerm } from "#api/authorize.js";
import { apiError, handleError, requireDb, unwrapResult } from "#api/error.js";
import { handleTermCreate, handleTermList } from "#api/handlers/taxonomies.js";
import { isParseError, parseBody, parseQuery } from "#api/parse.js";
import { createTermBody, localeFilterQuery } from "#api/schemas.js";
import { createTermBody, termListQuery } from "#api/schemas.js";

export const prerender = false;

Expand All @@ -29,11 +29,14 @@ export const GET: APIRoute = async ({ params, request, locals }) => {
const denied = requirePerm(user, "taxonomies:read");
if (denied) return denied;

const query = parseQuery(new URL(request.url), localeFilterQuery);
const query = parseQuery(new URL(request.url), termListQuery);
if (isParseError(query)) return query;

try {
const result = await handleTermList(emdash.db, name, { locale: query.locale });
const result = await handleTermList(emdash.db, name, {
locale: query.locale,
includeCounts: query.includeCounts,
});
return unwrapResult(result);
} catch (error) {
return handleError(error, "Failed to list terms", "TERM_LIST_ERROR");
Expand Down
Loading
Loading