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
8 changes: 8 additions & 0 deletions .changeset/trash-locale-filter.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 24 additions & 3 deletions packages/admin/src/components/ContentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="space-y-4">
Expand Down Expand Up @@ -755,6 +756,11 @@ export function ContentList({
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Title`}
</th>
{i18n && (
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Locale`}
</th>
)}
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Deleted`}
</th>
Expand All @@ -766,7 +772,7 @@ export function ContentList({
<tbody className="divide-y divide-kumo-line">
{isTrashedLoading && trashedItems.length === 0 ? (
<tr>
<td colSpan={3} className="px-4 py-8 text-center text-kumo-subtle">
<td colSpan={trashColSpan} className="px-4 py-8 text-center text-kumo-subtle">
<span className="inline-flex items-center gap-2">
<Loader size="sm" />
{t`Loading...`}
Expand All @@ -775,7 +781,7 @@ export function ContentList({
</tr>
) : trashedItems.length === 0 ? (
<tr>
<td colSpan={3} className="px-4 py-8 text-center text-kumo-subtle">
<td colSpan={trashColSpan} className="px-4 py-8 text-center text-kumo-subtle">
{t`Trash is empty`}
</td>
</tr>
Expand All @@ -785,6 +791,7 @@ export function ContentList({
key={item.id}
item={item}
titleField={titleField}
showLocale={!!i18n}
onRestore={onRestore}
onPermanentDelete={onPermanentDelete}
/>
Expand Down Expand Up @@ -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);
Expand All @@ -1493,6 +1507,13 @@ function TrashedListItem({ item, titleField, onRestore, onPermanentDelete }: Tra
<td className="px-4 py-3">
<span className="font-medium text-kumo-subtle">{title}</span>
</td>
{showLocale && (
<td className="px-4 py-3">
<span className="bg-kumo-tint rounded px-1.5 py-0.5 text-xs font-semibold uppercase">
{item.locale}
</span>
</td>
)}
<td className="px-4 py-3 text-sm text-kumo-subtle">{deletedDate.toLocaleDateString()}</td>
<td className="px-4 py-3 text-end">
<div className="flex items-center justify-end space-x-1">
Expand Down
2 changes: 2 additions & 0 deletions packages/admin/src/lib/api/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,11 +311,13 @@ export async function fetchTrashedContent(
options?: {
cursor?: string;
limit?: number;
locale?: string;
},
): Promise<FindManyResult<TrashedContentItem>> {
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);
Expand Down
4 changes: 2 additions & 2 deletions packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
30 changes: 30 additions & 0 deletions packages/admin/tests/components/ContentList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ContentList
{...defaultProps}
items={[]}
trashedItems={[makeTrashedItem({ id: "t1", locale: "fr" })]}
i18n={{ defaultLocale: "en", locales: ["en", "fr"] }}
activeLocale="fr"
onLocaleChange={() => {}}
/>,
);
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(
<ContentList
{...defaultProps}
items={[]}
trashedItems={[makeTrashedItem({ id: "t1", locale: "en" })]}
/>,
);
await screen.getByText("Trash").click();
expect(screen.getByRole("columnheader", { name: "Locale" }).query()).toBeNull();
});
});

describe("status badges", () => {
Expand Down
35 changes: 35 additions & 0 deletions packages/admin/tests/lib/trash-locale.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;

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);
});
});
10 changes: 8 additions & 2 deletions packages/core/src/api/handlers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,8 @@ export interface TrashedContentItem {
type: string;
slug: string | null;
status: string;
locale: string | null;
translationGroup: string | null;
data: Record<string, unknown>;
authorId: string | null;
createdAt: string;
Expand Down Expand Up @@ -1406,13 +1408,14 @@ export async function handleContentPermanentDelete(
export async function handleContentListTrashed(
db: Kysely<Database>,
collection: string,
options: { limit?: number; cursor?: string } = {},
options: { limit?: number; cursor?: string; locale?: string } = {},
): Promise<ApiResult<{ items: TrashedContentItem[]; nextCursor?: string }>> {
try {
const repo = new ContentRepository(db);
const result = await repo.findTrashed(collection, {
limit: options.limit,
cursor: options.cursor,
where: { locale: options.locale },
});

return {
Expand All @@ -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,
Expand Down Expand Up @@ -1457,10 +1462,11 @@ export async function handleContentListTrashed(
export async function handleContentCountTrashed(
db: Kysely<Database>,
collection: string,
options: { locale?: string } = {},
): Promise<ApiResult<{ count: number }>> {
try {
const repo = new ContentRepository(db);
const count = await repo.countTrashed(collection);
const count = await repo.countTrashed(collection, { locale: options.locale });

return {
success: true,
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/api/schemas/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/astro/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HandlerResponse>;

handleContentRestore: (collection: string, id: string) => Promise<HandlerResponse>;

handleContentPermanentDelete: (collection: string, id: string) => Promise<HandlerResponse>;

handleContentCountTrashed: (collection: string) => Promise<HandlerResponse>;
handleContentCountTrashed: (
collection: string,
params?: { locale?: string },
) => Promise<HandlerResponse>;

handleContentGetIncludingTrashed: (collection: string, id: string) => Promise<HandlerResponse>;

Expand Down
19 changes: 14 additions & 5 deletions packages/core/src/database/repositories/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1129,7 +1129,7 @@ export class ContentRepository {
*/
async findTrashed(
type: string,
options: Omit<FindManyOptions, "where"> = {},
options: Omit<FindManyOptions, "where"> & { where?: { locale?: string } } = {},
): Promise<FindManyResult<ContentItem & { deletedAt: string }>> {
const tableName = getTableName(type);
const limit = Math.min(options.limit || 50, 100);
Expand All @@ -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);
Expand Down Expand Up @@ -1202,14 +1206,19 @@ export class ContentRepository {
/**
* Count trashed content items
*/
async countTrashed(type: string): Promise<number> {
async countTrashed(type: string, options: { locale?: string } = {}): Promise<number> {
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);
}
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading