diff --git a/.changeset/trash-locale-filter.md b/.changeset/trash-locale-filter.md new file mode 100644 index 0000000000..204239fd59 --- /dev/null +++ b/.changeset/trash-locale-filter.md @@ -0,0 +1,8 @@ +--- +"emdash": patch +"@emdash-cms/admin": patch +--- + +Fixes the admin Trash tab on multilingual sites, where it listed trashed entries from every locale regardless of the locale picker. Trash now follows the same locale filter as the All tab and shows a Locale column, so switching locales narrows the trash to that locale's entries. + +`GET /_emdash/api/content/{collection}/trash` accepts an optional `locale` query parameter to scope the listing, and each item in the response now carries `locale` and `translationGroup`. Omitting `locale` still returns every locale, so existing API callers are unaffected. diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index 0daf6f26c7..5231586a28 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -405,6 +405,7 @@ export function ContentList({ }; const colSpan = (i18n ? 5 : 4) + listColumns.length + extensionColumns.length + (bulkEnabled ? 1 : 0); + const trashColSpan = i18n ? 4 : 3; return (
@@ -755,6 +756,11 @@ export function ContentList({ {t`Title`} + {i18n && ( + + {t`Locale`} + + )} {t`Deleted`} @@ -766,7 +772,7 @@ export function ContentList({ {isTrashedLoading && trashedItems.length === 0 ? ( - + {t`Loading...`} @@ -775,7 +781,7 @@ export function ContentList({ ) : trashedItems.length === 0 ? ( - + {t`Trash is empty`} @@ -785,6 +791,7 @@ export function ContentList({ key={item.id} item={item} titleField={titleField} + showLocale={!!i18n} onRestore={onRestore} onPermanentDelete={onPermanentDelete} /> @@ -1479,11 +1486,18 @@ function scalarListColumnValue(value: unknown): string | undefined { interface TrashedListItemProps { item: TrashedContentItem; titleField?: string; + showLocale?: boolean; onRestore?: (id: string) => void; onPermanentDelete?: (id: string) => void; } -function TrashedListItem({ item, titleField, onRestore, onPermanentDelete }: TrashedListItemProps) { +function TrashedListItem({ + item, + titleField, + showLocale, + onRestore, + onPermanentDelete, +}: TrashedListItemProps) { const { t } = useLingui(); const title = getEntryTitle(item, titleField); const deletedDate = parseTimestamp(item.deletedAt); @@ -1493,6 +1507,13 @@ function TrashedListItem({ item, titleField, onRestore, onPermanentDelete }: Tra {title} + {showLocale && ( + + + {item.locale} + + + )} {deletedDate.toLocaleDateString()}
diff --git a/packages/admin/src/lib/api/content.ts b/packages/admin/src/lib/api/content.ts index 2fb0337ffc..6d77ed89bf 100644 --- a/packages/admin/src/lib/api/content.ts +++ b/packages/admin/src/lib/api/content.ts @@ -311,11 +311,13 @@ export async function fetchTrashedContent( options?: { cursor?: string; limit?: number; + locale?: string; }, ): Promise> { const params = new URLSearchParams(); if (options?.cursor) params.set("cursor", options.cursor); if (options?.limit) params.set("limit", String(options.limit)); + if (options?.locale) params.set("locale", options.locale); const url = `${API_BASE}/content/${collection}/trash${params.toString() ? `?${params}` : ""}`; const response = await apiFetch(url); diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index f5e4328d48..545c30e6d4 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -446,8 +446,8 @@ function ContentListPage() { // Fetch trashed items const { data: trashedData, isLoading: isTrashedLoading } = useQuery({ - queryKey: ["content", collection, "trash"], - queryFn: () => fetchTrashedContent(collection), + queryKey: ["content", collection, "trash", { locale: activeLocale }], + queryFn: () => fetchTrashedContent(collection, { locale: activeLocale }), }); const deleteMutation = useMutation({ diff --git a/packages/admin/tests/components/ContentList.test.tsx b/packages/admin/tests/components/ContentList.test.tsx index f55c9abe2c..4f875b1377 100644 --- a/packages/admin/tests/components/ContentList.test.tsx +++ b/packages/admin/tests/components/ContentList.test.tsx @@ -254,6 +254,36 @@ describe("ContentList", () => { ); await expect.element(screen.getByText("42")).toBeInTheDocument(); }); + + it("shows each trashed item's locale when i18n is configured", async () => { + const screen = await render( + {}} + />, + ); + await screen.getByText("Trash").click(); + await expect + .element(screen.getByRole("columnheader", { name: "Locale" })) + .toBeInTheDocument(); + await expect.element(screen.getByRole("cell", { name: "fr" })).toBeInTheDocument(); + }); + + it("omits the trash locale column on a single-locale site", async () => { + const screen = await render( + , + ); + await screen.getByText("Trash").click(); + expect(screen.getByRole("columnheader", { name: "Locale" }).query()).toBeNull(); + }); }); describe("status badges", () => { diff --git a/packages/admin/tests/lib/trash-locale.test.ts b/packages/admin/tests/lib/trash-locale.test.ts new file mode 100644 index 0000000000..91c780dcdf --- /dev/null +++ b/packages/admin/tests/lib/trash-locale.test.ts @@ -0,0 +1,35 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchTrashedContent } from "../../src/lib/api/content"; + +describe("trashed content API client", () => { + const originalFetch = globalThis.fetch; + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi + .fn() + .mockImplementation( + () => new Response(JSON.stringify({ data: { items: [] } }), { status: 200 }), + ); + globalThis.fetch = fetchSpy as typeof globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("scopes the trash listing to the active locale", async () => { + await fetchTrashedContent("posts", { locale: "fr" }); + + const [url] = fetchSpy.mock.calls[0]!; + expect(new URL(url, "http://localhost").searchParams.get("locale")).toBe("fr"); + }); + + it("omits the locale param when i18n is off", async () => { + await fetchTrashedContent("posts"); + + const [url] = fetchSpy.mock.calls[0]!; + expect(new URL(url, "http://localhost").searchParams.has("locale")).toBe(false); + }); +}); diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 0b54b6fdb0..30c2e1fe15 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -329,6 +329,8 @@ export interface TrashedContentItem { type: string; slug: string | null; status: string; + locale: string | null; + translationGroup: string | null; data: Record; authorId: string | null; createdAt: string; @@ -1406,13 +1408,14 @@ export async function handleContentPermanentDelete( export async function handleContentListTrashed( db: Kysely, collection: string, - options: { limit?: number; cursor?: string } = {}, + options: { limit?: number; cursor?: string; locale?: string } = {}, ): Promise> { try { const repo = new ContentRepository(db); const result = await repo.findTrashed(collection, { limit: options.limit, cursor: options.cursor, + where: { locale: options.locale }, }); return { @@ -1423,6 +1426,8 @@ export async function handleContentListTrashed( type: item.type, slug: item.slug, status: item.status, + locale: item.locale, + translationGroup: item.translationGroup, data: item.data, authorId: item.authorId, createdAt: item.createdAt, @@ -1457,10 +1462,11 @@ export async function handleContentListTrashed( export async function handleContentCountTrashed( db: Kysely, collection: string, + options: { locale?: string } = {}, ): Promise> { try { const repo = new ContentRepository(db); - const count = await repo.countTrashed(collection); + const count = await repo.countTrashed(collection, { locale: options.locale }); return { success: true, diff --git a/packages/core/src/api/schemas/content.ts b/packages/core/src/api/schemas/content.ts index f2142ad99f..eb55b78e0b 100644 --- a/packages/core/src/api/schemas/content.ts +++ b/packages/core/src/api/schemas/content.ts @@ -249,7 +249,13 @@ export const contentTermsBody = z }) .meta({ id: "ContentTermsBody" }); -export const contentTrashQuery = cursorPaginationQuery; +export const contentTrashQuery = cursorPaginationQuery + .extend({ + locale: localeCode.optional().meta({ + description: "Restrict the trash listing to entries in this locale", + }), + }) + .meta({ id: "ContentTrashQuery" }); // --------------------------------------------------------------------------- // Content: Response schemas @@ -337,6 +343,8 @@ export const trashedContentItemSchema = z type: z.string(), slug: z.string().nullable(), status: z.string(), + locale: z.string().nullable(), + translationGroup: z.string().nullable(), data: z.record(z.string(), z.unknown()), authorId: z.string().nullable(), createdAt: z.string(), diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 869bbcff89..e637300ac5 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -329,14 +329,17 @@ export interface EmDashHandlers { // Trash handlers handleContentListTrashed: ( collection: string, - params?: { cursor?: string; limit?: number }, + params?: { cursor?: string; limit?: number; locale?: string }, ) => Promise; handleContentRestore: (collection: string, id: string) => Promise; handleContentPermanentDelete: (collection: string, id: string) => Promise; - handleContentCountTrashed: (collection: string) => Promise; + handleContentCountTrashed: ( + collection: string, + params?: { locale?: string }, + ) => Promise; handleContentGetIncludingTrashed: (collection: string, id: string) => Promise; diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index 9a15eb4c79..cdc899b412 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -1129,7 +1129,7 @@ export class ContentRepository { */ async findTrashed( type: string, - options: Omit = {}, + options: Omit & { where?: { locale?: string } } = {}, ): Promise> { const tableName = getTableName(type); const limit = Math.min(options.limit || 50, 100); @@ -1146,6 +1146,10 @@ export class ContentRepository { .selectAll() .where("deleted_at" as never, "is not", null); + if (options.where?.locale) { + query = query.where("locale" as any, "=", options.where.locale); + } + // Handle cursor pagination — decodeCursor throws on invalid input. if (options.cursor) { const { orderValue, id: cursorId } = decodeCursor(options.cursor); @@ -1202,14 +1206,19 @@ export class ContentRepository { /** * Count trashed content items */ - async countTrashed(type: string): Promise { + async countTrashed(type: string, options: { locale?: string } = {}): Promise { const tableName = getTableName(type); - const result = await this.db + let query = this.db .selectFrom(tableName as keyof Database) .select((eb) => eb.fn.count("id").as("count")) - .where("deleted_at" as never, "is not", null) - .executeTakeFirst(); + .where("deleted_at" as never, "is not", null); + + if (options.locale) { + query = query.where("locale" as any, "=", options.locale); + } + + const result = await query.executeTakeFirst(); return Number(result?.count || 0); } diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 4565ac6b98..a288223c7a 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -3238,7 +3238,7 @@ export class EmDashRuntime { async handleContentListTrashed( collection: string, - params: { cursor?: string; limit?: number } = {}, + params: { cursor?: string; limit?: number; locale?: string } = {}, ) { return handleContentListTrashed(this.db, collection, params); } @@ -3271,8 +3271,8 @@ export class EmDashRuntime { return result; } - async handleContentCountTrashed(collection: string) { - return handleContentCountTrashed(this.db, collection); + async handleContentCountTrashed(collection: string, params: { locale?: string } = {}) { + return handleContentCountTrashed(this.db, collection, params); } async handleContentDuplicate(collection: string, id: string, authorId?: string) { diff --git a/packages/core/tests/integration/content/trash-locale-filter.test.ts b/packages/core/tests/integration/content/trash-locale-filter.test.ts new file mode 100644 index 0000000000..49f5ea1a53 --- /dev/null +++ b/packages/core/tests/integration/content/trash-locale-filter.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, afterEach, expect, it } from "vitest"; + +import { + handleContentCountTrashed, + handleContentListTrashed, +} from "../../../src/api/handlers/content.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("trashed content locale scoping", (dialect) => { + let ctx: DialectTestContext; + let repo: ContentRepository; + + beforeEach(async () => { + ctx = await setupForDialect(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" }); + + repo = new ContentRepository(ctx.db); + + const en = await repo.create({ + type: "posts", + slug: "hello-en", + locale: "en", + data: { title: "Hello" }, + }); + const fr = await repo.create({ + type: "posts", + slug: "hello-fr", + locale: "fr", + translationOf: en.id, + data: { title: "Bonjour" }, + }); + const de = await repo.create({ + type: "posts", + slug: "hallo-de", + locale: "de", + data: { title: "Hallo" }, + }); + + await repo.delete("posts", en.id); + await repo.delete("posts", fr.id); + await repo.delete("posts", de.id); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("lists only the trashed entries in the requested locale", async () => { + const result = await handleContentListTrashed(ctx.db, "posts", { locale: "fr" }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.items.map((item) => item.slug)).toEqual(["hello-fr"]); + }); + + it("lists every locale when no locale is given", async () => { + const result = await handleContentListTrashed(ctx.db, "posts", {}); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(new Set(result.data.items.map((item) => item.slug))).toEqual( + new Set(["hello-en", "hello-fr", "hallo-de"]), + ); + }); + + it("returns each item's locale so the trash list can display it", async () => { + const result = await handleContentListTrashed(ctx.db, "posts", { locale: "de" }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.items[0]?.locale).toBe("de"); + }); + + it("counts only the trashed entries in the requested locale", async () => { + const scoped = await handleContentCountTrashed(ctx.db, "posts", { locale: "en" }); + const all = await handleContentCountTrashed(ctx.db, "posts"); + + expect(scoped.success && scoped.data.count).toBe(1); + expect(all.success && all.data.count).toBe(3); + }); +});