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
13 changes: 13 additions & 0 deletions .changeset/fix-menus-rest-shape-and-id-routes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"emdash": minor
"@emdash-cms/admin": minor
---

Fixes menu REST API consistency:

- **`POST /menus/:name/items` no longer accepts unknown keys silently.** Sending `custom_url` (snake_case) or `url` used to return 201 with `custom_url: null` because Zod's default `.strip()` quietly dropped them. The schemas now use `.strict()` and return **400 `VALIDATION_ERROR`** with `Unrecognized key: "custom_url"`. The documented camelCase keys (`customUrl`, `sortOrder`, `referenceCollection`, etc.) are unchanged and persist as before. The `type` field is now validated against the canonical enum (`"custom" | "page" | "post" | "taxonomy" | "collection"`); previously any string passed.
- **Moves per-item writes to `PUT` and `DELETE /menus/:name/items/:id` (path-style).** Every other EmDash resource (`content`, `taxonomies`, `redirects`, `sections`, `widget-areas`) addresses items by URL path; menus were the lone outlier requiring `?id=<id>` in the query string. The legacy query-string form is **removed** (it was undocumented and only used by the admin, which is updated in this PR). Callers should use `PUT /menus/:name/items/:id` / `DELETE /menus/:name/items/:id`.
- **Menu and menu-item API responses are now camelCase**, aligning with the rest of EmDash's REST surface (`content`, `taxonomies`, `redirects`, …). `created_at` → `createdAt`, `updated_at` → `updatedAt`, `menu_id` → `menuId`, `parent_id` → `parentId`, `sort_order` → `sortOrder`, `reference_collection` → `referenceCollection`, `reference_id` → `referenceId`, `custom_url` → `customUrl`, `title_attr` → `titleAttr`, `css_classes` → `cssClasses`, `translation_group` → `translationGroup`. **Breaking** for direct REST consumers that depend on snake_case keys in the response body. The admin UI is already updated.
- **Refactors menus to the standard repository pattern.** Adds `MenuRepository` next to `ContentRepository`, `TaxonomyRepository`, `RedirectRepository`, `MediaRepository`, `CommentRepository`. Handlers become thin orchestrators; the repository is now the single place where snake_case rows become camelCase entities.

These changes do not touch any database schema or migration. Existing data is preserved.
8 changes: 4 additions & 4 deletions packages/admin/src/components/MenuEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ export function MenuEditor() {
// Update sort orders
const reorderedItems = newItems.map((item, i) => ({
id: item.id,
parentId: item.parent_id,
parentId: item.parentId,
sortOrder: i,
}));

Expand Down Expand Up @@ -418,10 +418,10 @@ export function MenuEditor() {
<div className="font-medium">{item.label}</div>
<div className="text-sm text-kumo-subtle">
{item.type === "custom" ? (
item.custom_url
item.customUrl
) : (
<span className="inline-flex items-center rounded-full bg-kumo-brand/10 px-2 py-0.5 text-xs font-medium text-kumo-brand">
{item.reference_collection ?? item.type}
{item.referenceCollection ?? item.type}
</span>
)}
{item.target === "_blank" && t` (opens in new window)`}
Expand Down Expand Up @@ -504,7 +504,7 @@ export function MenuEditor() {
required
pattern="(https?://.+|/.*)"
title={t`Enter a URL (https://…) or a relative path (/…)`}
defaultValue={editingItem.custom_url || ""}
defaultValue={editingItem.customUrl || ""}
/>
)}
<Select
Expand Down
30 changes: 15 additions & 15 deletions packages/admin/src/lib/api/menus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,29 +15,29 @@ export interface Menu {
id: string;
name: string;
label: string;
created_at: string;
updated_at: string;
createdAt: string;
updatedAt: string;
itemCount?: number;
locale: string;
translation_group: string | null;
translationGroup: string | null;
}

export interface MenuItem {
id: string;
menu_id: string;
parent_id: string | null;
sort_order: number;
menuId: string;
parentId: string | null;
sortOrder: number;
type: string;
reference_collection: string | null;
reference_id: string | null;
custom_url: string | null;
referenceCollection: string | null;
referenceId: string | null;
customUrl: string | null;
label: string;
title_attr: string | null;
titleAttr: string | null;
target: string | null;
css_classes: string | null;
created_at: string;
cssClasses: string | null;
createdAt: string;
locale: string;
translation_group: string | null;
translationGroup: string | null;
}

export interface MenuWithItems extends Menu {
Expand Down Expand Up @@ -192,7 +192,7 @@ export async function updateMenuItem(
options: LocaleOptions = {},
): Promise<MenuItem> {
const response = await apiFetch(
withLocale(`${API_BASE}/menus/${menuName}/items?id=${itemId}`, options.locale),
withLocale(`${API_BASE}/menus/${menuName}/items/${itemId}`, options.locale),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
Expand All @@ -211,7 +211,7 @@ export async function deleteMenuItem(
options: LocaleOptions = {},
): Promise<void> {
const response = await apiFetch(
withLocale(`${API_BASE}/menus/${menuName}/items?id=${itemId}`, options.locale),
withLocale(`${API_BASE}/menus/${menuName}/items/${itemId}`, options.locale),
{ method: "DELETE" },
);
if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to delete menu item`));
Expand Down
46 changes: 26 additions & 20 deletions packages/admin/tests/components/MenuEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,38 +49,44 @@ const defaultMenu = {
id: "menu1",
name: "main-menu",
label: "Main Menu",
created_at: "",
updated_at: "",
createdAt: "",
updatedAt: "",
locale: "en",
translationGroup: "menu1",
items: [
{
id: "1",
menu_id: "menu1",
parent_id: null,
sort_order: 0,
menuId: "menu1",
parentId: null,
sortOrder: 0,
type: "custom",
reference_collection: null,
reference_id: null,
custom_url: "/",
referenceCollection: null,
referenceId: null,
customUrl: "/",
label: "Home",
title_attr: null,
titleAttr: null,
target: "_self",
css_classes: null,
created_at: "",
cssClasses: null,
createdAt: "",
locale: "en",
translationGroup: "1",
},
{
id: "2",
menu_id: "menu1",
parent_id: null,
sort_order: 1,
menuId: "menu1",
parentId: null,
sortOrder: 1,
type: "custom",
reference_collection: null,
reference_id: null,
custom_url: "/about",
referenceCollection: null,
referenceId: null,
customUrl: "/about",
label: "About",
title_attr: null,
titleAttr: null,
target: "_self",
css_classes: null,
created_at: "",
cssClasses: null,
createdAt: "",
locale: "en",
translationGroup: "2",
},
],
};
Expand Down
Loading
Loading