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/breezy-taxes-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fixes multiSelect custom fields rendering as plain text inputs instead of a checkbox group.
28 changes: 28 additions & 0 deletions packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Badge,
Button,
Checkbox,
Dialog,
Input,
InputArea,
Expand Down Expand Up @@ -1156,6 +1157,33 @@ function FieldRenderer({
);
}

case "multiSelect": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit

field.options?.map silently renders an empty group if a multiSelect field is defined without options. This is acceptably defensive, but a schema-level validation requiring options on multiSelect fields would be more robust.

const selected: string[] = Array.isArray(value) ? (value as string[]) : [];
return (
<fieldset>
<Label className={labelClass}>{label}</Label>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-2">
{field.options?.map((opt) => {
const isChecked = selected.includes(opt.value);
return (
<Checkbox
key={opt.value}
label={opt.label}
checked={isChecked}
onCheckedChange={(checked) => {
const next = checked
? [...selected, opt.value]
: selected.filter((v) => v !== opt.value);
handleChange(next);
}}
/>
);
})}
</div>
</fieldset>
);
}

case "datetime":
return (
<Input
Expand Down
14 changes: 13 additions & 1 deletion packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,12 @@ function ContentEditPage() {
void queryClient.invalidateQueries({ queryKey: ["content", collection, id] });
// Also invalidate revisions since a new one was created
void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] });
// Invalidate the cached draft revision so stale data doesn't overwrite the form
if (rawItem?.draftRevisionId) {
void queryClient.invalidateQueries({
queryKey: ["revision", rawItem.draftRevisionId],
});
}
},
onError: (error) => {
toastManager.add({
Expand All @@ -623,8 +629,14 @@ function ContentEditPage() {
}) => updateContent(collection, id, { ...data, skipRevision: true }),
onSuccess: () => {
setLastAutosaveAt(new Date());
// Silently update the cache without full invalidation
// Invalidate content and draft revision so stale cached data
// doesn't overwrite the form via the sync effect
void queryClient.invalidateQueries({ queryKey: ["content", collection, id] });
if (rawItem?.draftRevisionId) {
void queryClient.invalidateQueries({
queryKey: ["revision", rawItem.draftRevisionId],
});
}
},
onError: (err) => {
toastManager.add({
Expand Down
118 changes: 118 additions & 0 deletions packages/admin/tests/components/ContentEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,124 @@ describe("ContentEditor", () => {
const input = screen.getByLabelText("Order");
await expect.element(input).toHaveAttribute("type", "number");
});

it("renders select fields as select dropdowns", async () => {
const screen = await renderEditor({
fields: {
color: {
kind: "select",
label: "Color",
options: [
{ value: "red", label: "Red" },
{ value: "blue", label: "Blue" },
],
},
},
});
// Select renders a combobox role
const select = screen.getByRole("combobox");
await expect.element(select).toBeInTheDocument();
});

it("renders multiSelect fields as checkbox group", async () => {
const screen = await renderEditor({
fields: {
tags: {
kind: "multiSelect",
label: "Tags",
options: [
{ value: "news", label: "News" },
{ value: "tech", label: "Tech" },
{ value: "sports", label: "Sports" },
],
},
},
});
const checkboxes = screen.getByRole("checkbox", { exact: false });
await expect.element(checkboxes.first()).toBeInTheDocument();
// All option labels should be present
await expect.element(screen.getByText("News")).toBeInTheDocument();
await expect.element(screen.getByText("Tech")).toBeInTheDocument();
await expect.element(screen.getByText("Sports")).toBeInTheDocument();
});

it("toggling a multiSelect checkbox updates the saved value", async () => {
const onSave = vi.fn();
const item = makeItem({
data: { title: "Test", tags: ["news", "sports"] },
});
const screen = await renderEditor({
isNew: false,
item,
onSave,
fields: {
title: { kind: "string", label: "Title", required: true },
tags: {
kind: "multiSelect",
label: "Tags",
options: [
{ value: "news", label: "News" },
{ value: "tech", label: "Tech" },
{ value: "sports", label: "Sports" },
],
},
},
});

const checkboxes = screen.getByRole("checkbox", { exact: false });
const all = checkboxes.all();

// Uncheck "sports" (index 2, currently checked)
await all[2]!.click();
await expect.element(all[2]!).not.toBeChecked();

// Check "tech" (index 1, currently unchecked)
await all[1]!.click();
await expect.element(all[1]!).toBeChecked();

// Save and verify the data sent to onSave
const saveBtn = screen.getByRole("button", { name: "Save" });
await saveBtn.click();

expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
tags: ["news", "tech"],
}),
}),
);
});

it("multiSelect checkboxes reflect existing values", async () => {
const item = makeItem({
data: { title: "Test", tags: ["news", "sports"] },
});
const screen = await renderEditor({
isNew: false,
item,
fields: {
title: { kind: "string", label: "Title", required: true },
tags: {
kind: "multiSelect",
label: "Tags",
options: [
{ value: "news", label: "News" },
{ value: "tech", label: "Tech" },
{ value: "sports", label: "Sports" },
],
},
},
});
// Verify the checkbox group renders with correct checked state via aria
const checkboxes = screen.getByRole("checkbox", { exact: false });
const all = checkboxes.all();
// Should have 3 checkboxes
expect(all).toHaveLength(3);
// news (checked), tech (unchecked), sports (checked)
await expect.element(all[0]!).toBeChecked();
await expect.element(all[1]!).not.toBeChecked();
await expect.element(all[2]!).toBeChecked();
});
});

describe("saving", () => {
Expand Down
50 changes: 50 additions & 0 deletions packages/core/tests/database/repositories/content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,56 @@ describe("ContentRepository", () => {
);
});

it("should persist removal of array items in JSON fields (multiSelect)", async () => {
// Add a multiSelect (JSON) field to the post collection
await registry.createField("post", {
slug: "tags",
label: "Tags",
type: "multiSelect",
});

const created = await repo.create({
type: "post",
data: { title: "Test", tags: ["news", "sports", "tech"] },
});

expect(created.data.tags).toEqual(["news", "sports", "tech"]);

// Remove "sports" from the array (simulates unchecking a checkbox)
const updated = await repo.update("post", created.id, {
data: { title: "Test", tags: ["news", "tech"] },
});

expect(updated.data.tags).toEqual(["news", "tech"]);

// Verify it persists when re-reading
const fetched = await repo.findById("post", updated.id);
expect(fetched!.data.tags).toEqual(["news", "tech"]);
});

it("should persist empty array in JSON fields (multiSelect)", async () => {
await registry.createField("post", {
slug: "categories",
label: "Categories",
type: "multiSelect",
});

const created = await repo.create({
type: "post",
data: { title: "Test", categories: ["news"] },
});

// Uncheck all items
const updated = await repo.update("post", created.id, {
data: { title: "Test", categories: [] },
});

expect(updated.data.categories).toEqual([]);

const fetched = await repo.findById("post", updated.id);
expect(fetched!.data.categories).toEqual([]);
});

it("should not update soft-deleted content", async () => {
const created = await repo.create({
type: "post",
Expand Down
Loading