From 25aaba570e750b4199bdd677e79c432d9a0c8c7a Mon Sep 17 00:00:00 2001 From: Dennis Klappe Date: Mon, 22 Jun 2026 22:36:16 +0200 Subject: [PATCH] feat(admin): support hidden repeater sub-fields Repeater rows sometimes carry a stable identifier a template relies on (such as a copy-lookup key) that editors should never change. A hidden sub-field keeps that value in the entry data without rendering it in the editor, so it cannot be accidentally changed and the lookup stays intact. --- .changeset/hidden-repeater-subfields.md | 6 +++ .../admin/src/components/RepeaterField.tsx | 26 ++++++----- .../tests/components/RepeaterField.test.tsx | 45 +++++++++++++++++++ packages/core/src/api/schemas/schema.ts | 1 + packages/core/src/schema/types.ts | 1 + 5 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 .changeset/hidden-repeater-subfields.md diff --git a/.changeset/hidden-repeater-subfields.md b/.changeset/hidden-repeater-subfields.md new file mode 100644 index 000000000..f7c72bcd6 --- /dev/null +++ b/.changeset/hidden-repeater-subfields.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/admin": minor +"emdash": minor +--- + +Repeater sub-fields can now be marked `hidden: true`. A hidden sub-field is kept in the entry data but not shown in the content editor. This is useful for a stable identifier a template relies on (such as a copy-lookup key) that editors should never change. diff --git a/packages/admin/src/components/RepeaterField.tsx b/packages/admin/src/components/RepeaterField.tsx index 7b27fb36c..e0a09aafb 100644 --- a/packages/admin/src/components/RepeaterField.tsx +++ b/packages/admin/src/components/RepeaterField.tsx @@ -31,6 +31,8 @@ interface RepeaterSubFieldDef { label: string; required?: boolean; options?: string[]; + // Not rendered in the editor, but its value is preserved on save. + hidden?: boolean; } export interface RepeaterFieldProps { @@ -233,8 +235,10 @@ function SortableRepeaterItem({ transition, }; - // Use the first text sub-field as the item summary label - const summaryField = subFields.find((sf) => sf.type === "string" || sf.type === "text"); + // Use the first visible text sub-field as the item summary label + const summaryField = subFields.find( + (sf) => !sf.hidden && (sf.type === "string" || sf.type === "text"), + ); const summaryValue = summaryField ? (item[summaryField.slug] as string) || "" : ""; const summaryLabel = summaryValue || t`Item ${index + 1}`; @@ -282,14 +286,16 @@ function SortableRepeaterItem({ {/* Sub-fields */} {!isCollapsed && (
- {subFields.map((sf) => ( - onChange(sf.slug, v)} - /> - ))} + {subFields + .filter((sf) => !sf.hidden) + .map((sf) => ( + onChange(sf.slug, v)} + /> + ))}
)} diff --git a/packages/admin/tests/components/RepeaterField.test.tsx b/packages/admin/tests/components/RepeaterField.test.tsx index d507e0187..88adf18ee 100644 --- a/packages/admin/tests/components/RepeaterField.test.tsx +++ b/packages/admin/tests/components/RepeaterField.test.tsx @@ -115,3 +115,48 @@ describe("RepeaterField sub-field types", () => { expect(onChange).toHaveBeenCalledWith([{ image: null, caption: "" }]); }); }); + +/** + * Hidden sub-fields: not rendered in the editor, but their values are kept so + * templates can rely on a stable identifier the editor never sees. + */ +describe("RepeaterField hidden sub-fields", () => { + const subFields = [ + { slug: "value", type: "text", label: "Text" }, + { slug: "key", type: "string", label: "Key", hidden: true }, + ]; + + it("does not render hidden sub-fields", async () => { + const screen = await render( + , + ); + + await expect.element(screen.getByRole("textbox", { name: "Text" })).toBeVisible(); + expect(screen.getByRole("textbox", { name: "Key" }).elements()).toHaveLength(0); + }); + + it("preserves hidden sub-field values when a visible field changes", async () => { + const onChange = vi.fn(); + const screen = await render( + , + ); + + await screen.getByRole("textbox", { name: "Text" }).fill("Hello"); + + expect(onChange).toHaveBeenLastCalledWith([ + expect.objectContaining({ key: "home.title", value: "Hello" }), + ]); + }); +}); diff --git a/packages/core/src/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts index 48307ecfb..62e4f65af 100644 --- a/packages/core/src/api/schemas/schema.ts +++ b/packages/core/src/api/schemas/schema.ts @@ -47,6 +47,7 @@ const repeaterSubFieldSchema = z.object({ label: z.string().min(1), required: z.boolean().optional(), options: z.array(z.string()).optional(), + hidden: z.boolean().optional(), }); const fieldValidation = z diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index f95f42e4d..49274b82b 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -115,6 +115,7 @@ export interface RepeaterSubField { label: string; required?: boolean; options?: string[]; // For select sub-fields + hidden?: boolean; // Not rendered in the editor; value preserved on save } /** Allowed types for repeater sub-fields (no nesting, no complex types) */