diff --git a/.changeset/breezy-taxes-joke.md b/.changeset/breezy-taxes-joke.md new file mode 100644 index 000000000..575ffcba3 --- /dev/null +++ b/.changeset/breezy-taxes-joke.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes multiSelect custom fields rendering as plain text inputs instead of a checkbox group. diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 99aff75a2..8af271fea 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -1,6 +1,7 @@ import { Badge, Button, + Checkbox, Dialog, Input, InputArea, @@ -1156,6 +1157,33 @@ function FieldRenderer({ ); } + case "multiSelect": { + const selected: string[] = Array.isArray(value) ? (value as string[]) : []; + return ( +
+ +
+ {field.options?.map((opt) => { + const isChecked = selected.includes(opt.value); + return ( + { + const next = checked + ? [...selected, opt.value] + : selected.filter((v) => v !== opt.value); + handleChange(next); + }} + /> + ); + })} +
+
+ ); + } + case "datetime": return ( { toastManager.add({ @@ -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({ diff --git a/packages/admin/tests/components/ContentEditor.test.tsx b/packages/admin/tests/components/ContentEditor.test.tsx index 57359ca25..a86719f02 100644 --- a/packages/admin/tests/components/ContentEditor.test.tsx +++ b/packages/admin/tests/components/ContentEditor.test.tsx @@ -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", () => { diff --git a/packages/core/tests/database/repositories/content.test.ts b/packages/core/tests/database/repositories/content.test.ts index 0c9ad8323..0095f1d6f 100644 --- a/packages/core/tests/database/repositories/content.test.ts +++ b/packages/core/tests/database/repositories/content.test.ts @@ -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",