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) */