Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/hidden-repeater-subfields.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 16 additions & 10 deletions packages/admin/src/components/RepeaterField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}`;

Expand Down Expand Up @@ -282,14 +286,16 @@ function SortableRepeaterItem({
{/* Sub-fields */}
{!isCollapsed && (
<div className="p-3 space-y-3">
{subFields.map((sf) => (
<SubFieldInput
key={sf.slug}
subField={sf}
value={item[sf.slug]}
onChange={(v) => onChange(sf.slug, v)}
/>
))}
{subFields
.filter((sf) => !sf.hidden)

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.

[needs fixing] This render filter correctly skips hidden sub-fields, but the flag is not preserved when a repeater field is saved through the admin schema editor. FieldEditor.tsx:294-299 rebuilds each sub-field as { slug, type, label, required }, dropping hidden (and options), and RepeaterSubFieldState (FieldEditor.tsx:55) doesn't even declare hidden.

Consequence: a repeater field defined with hidden: true sub-fields (via f(...) or the API) loses every hidden flag the moment someone opens that field in the admin schema editor and saves — even if they only rename the field or add another sub-field. The previously-hidden identifier then becomes visible and editable, which is exactly the footgun this feature exists to prevent. The PR updated the type, the zod validator, and the renderer, but missed this sibling save path.

Fix in FieldEditor.tsx — add hidden?: boolean to RepeaterSubFieldState and carry it through the save map:

(validation as Record<string, unknown>).subFields = formState.subFields.map((sf) => ({
	slug: sf.slug,
	type: sf.type,
	label: sf.label,
	required: sf.required || undefined,
	hidden: sf.hidden || undefined,
}));

(The same map also drops options for select sub-fields — a pre-existing gap worth a follow-up, but out of scope for this PR.)

.map((sf) => (
<SubFieldInput
key={sf.slug}
subField={sf}
value={item[sf.slug]}
onChange={(v) => onChange(sf.slug, v)}
/>
))}
</div>
)}
</div>
Expand Down
45 changes: 45 additions & 0 deletions packages/admin/tests/components/RepeaterField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<RepeaterField
label="Texts"
id="texts"
value={[{ key: "home.title", value: "Welcome" }]}
onChange={vi.fn()}
subFields={subFields}
/>,
);

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(
<RepeaterField
label="Texts"
id="texts"
value={[{ key: "home.title", value: "Welcome" }]}
onChange={onChange}
subFields={subFields}
/>,
);

await screen.getByRole("textbox", { name: "Text" }).fill("Hello");

expect(onChange).toHaveBeenLastCalledWith([
expect.objectContaining({ key: "home.title", value: "Hello" }),
]);
});
});
1 change: 1 addition & 0 deletions packages/core/src/api/schemas/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/schema/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down
Loading