Skip to content

Commit 3a4a6fc

Browse files
DavidBabinecclaude
andauthored
feat(content): edit custom data-table fields in the content settings panel (CoreBunch#182)
* feat(content): edit custom data-table fields in the content settings panel The Content workspace settings panel previously exposed only the built-in entry fields (slug, status, author, featured media, SEO meta). Custom columns added to the posts/pages data tables were invisible there — they could only be edited in the Data workspace grid. - `defaultCellValue` in `src/core/data/cells.ts` centralizes per-type empty values (shared by the grid and the new panel editors) - `useContentEntryDraft` carries a `customCells` record; dirty tracking, hydration, and save payloads include custom columns - `ContentCustomFields` (lazy-loaded to keep the ContentPage chunk within its bundle budget) renders one editor per custom column: Input for text/number/date/datetime, Switch for boolean, Select for select, CodeMirror-free textarea for json, FileUpload-backed picker for media, and a RelationPickerDialog for relation columns - `useRelationTargetRows` resolves relation ids to target-row labels for display - rename/duplicate drafts snapshot custom cells so they survive those flows - docs: content-workspace.md + content-storage.md updated Verified with bun test, bun run build, bun run lint, and a browser smoke test (seeded five custom columns on posts; edited, saved, reloaded — all values persisted and rehydrated). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(content): cover custom field template bindings --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 72a0316 commit 3a4a6fc

15 files changed

Lines changed: 704 additions & 8 deletions

docs/e2e/feature-matrix.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ BUILDER-008 note: rich-body bold/italic persistence and public entry-template re
161161

162162
SITE-017 note: `visual-builder.e2e.ts` componentizes a Text node, adds a Slot Outlet in VC mode, returns to the page, inserts Text into the locked generated slot, saves, publishes, and verifies an anonymous visitor sees both component body and slot fill while editor-only slot labels/component names stay absent. DEF-20260623-SITE017-001 fixed the page/component incremental-save ordering bug by writing component rows before page rows.
163163

164-
SITE-018 note: `visual-builder.e2e.ts` creates a high-priority Posts template from the Site panel, sets Template settings to Post types/Posts, inserts `{currentEntry.title}` through the binding picker, verifies synthetic canvas preview for title/body, saves and publishes the template snapshot, publishes a post, and verifies an anonymous `/posts/<slug>` visitor route renders the custom template with the published row data and no unresolved tokens.
164+
SITE-018 note: `visual-builder.e2e.ts` creates a high-priority Posts template from the Site panel, sets Template settings to Post types/Posts, inserts `{currentEntry.title}` through the binding picker, verifies synthetic canvas preview for title/body, saves and publishes the template snapshot, publishes a post, and verifies an anonymous `/posts/<slug>` visitor route renders the custom template with the published row data and no unresolved tokens. `content.e2e.ts` extends the binding coverage by adding a custom Posts field, inserting `{currentEntry.<customField>}` from the binding picker, verifying canvas preview resolution, publishing the template, and verifying the public post route resolves the custom value.
165165

166166
SITE-019 note: `visual-builder.e2e.ts` saves a styled Container subtree as a layout, verifies blank and duplicate-name validation, confirms the Layouts category remains reachable at 390px, inserts the saved layout into another page with its captured class styling, renames and deletes the saved layout from the inserter manage menu, saves/reloads, publishes, and verifies anonymous public output. DEF-20260623-SITE019-001 fixed stale node selection on `addPage`; DEF-20260623-SITE019-002 fixed count-only mobile category buttons; DEF-20260623-SITE019-003 fixed the saved-layout manage menu z-index under the spotlight inserter.
167167

@@ -249,6 +249,7 @@ MEDIA-007 note: unsafe SVG upload sanitization and public `/uploads` serving are
249249
| CONTENT-005 | P2 | partial | Live preview | Preview an entry inside its site template | Existing entry and template | Content live mode | Preview matches current draft/template context | missing template, stale draft/public state |
250250
| CONTENT-006 | P2 | partial | Collections | Create/update collection field settings | Logged in | Content collections/settings | Field changes are reflected in the entry editor | destructive schema changes, step-up friction |
251251
| CONTENT-007 | P2 | partial | AI | Use the content AI assistant panel | AI chat permission | Content AI panel | No-provider guidance or chat flow is understandable | provider failure, write-tool permission leaks |
252+
| CONTENT-008 | P1 || Custom fields | Edit custom post fields and use them in Site templates | Logged in | Data Posts fields → Content settings → Site binding picker → publish post route | Custom field appears in the Content entry settings panel, persists after reload, is offered as a scoped currentEntry binding, resolves in canvas preview, and renders on the public post route | stale table metadata, unresolved custom tokens, save/publish ordering |
252253

253254
CONTENT-003 note: slash-menu Heading 2 and Data token placeholder insertion with save/reload persistence are automated in `content.e2e.ts`; media picker insertion and sanitization edge cases remain lower-level or future browser coverage.
254255

@@ -258,6 +259,8 @@ CONTENT-006 note: content built-in field toggles are automated in `content.e2e.t
258259

259260
CONTENT-007 note: no-provider setup guidance in the content AI assistant is automated in `content.e2e.ts`; provider-backed conversation, streaming, and write-tool flows remain future browser coverage.
260261

262+
CONTENT-008 note: `content.e2e.ts` adds a custom text field to the system Posts table through the Data inspector, edits that field in the Content settings sidebar, saves and reloads the entry, publishes the post, inserts the custom field from the Site builder binding picker into a Posts template, verifies the canvas preview resolves the value, publishes the template, and verifies an anonymous `/posts/<slug>` route renders the custom value without unresolved tokens. Non-text custom field editor variants remain lower-level or future matrix expansion.
263+
261264
## AI Workspace
262265

263266
| ID | Priority | Auto | Area | User Goal | Setup | Path | Expected Outcome | Watch For |

docs/features/content-storage.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ For **post-types**, public row routes require an explicitly authored entry templ
246246
2. Add a field (pick a type from `DataFieldType`).
247247
3. Optionally mark it `required` or set a `defaultValue`.
248248

249-
The field appears in the postType's edit form and is queryable from loops.
249+
The field appears in the postType's edit form in the Data workspace, in the Content page's settings panel, and is queryable from loops.
250250

251251
### Create a custom post-type
252252

docs/features/content-workspace.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ Collections are `data_tables` with `kind: 'postType'`. The four system tables (`
119119
`AdminWorkspaceCanvasLayout` treats an absent `contentRightPanel` as "no right panel available",
120120
so a persisted open state never reserves an empty right rail on a fresh install or after the last entry is cleared.
121121

122-
The panel exposes: status selector, slug, author (if the user has `canEditAnyContent`), collection (move entry), SEO title, SEO description, featured media.
122+
The panel exposes: status selector, slug, author (if the user has `canEditAnyContent`), collection (move entry), SEO title, SEO description, featured media — followed by the collection's **custom (non-built-in) fields**, rendered generically through the Data workspace's `CellEditorRenderer` (`context="detail"`). Relation fields open the shared `RelationPickerDialog` (target-table rows resolved for display by `useRelationTargetRows`); media fields carry their own picker. `pageTree` / `fieldSchema` fields are excluded — their cells hold whole documents, not values. Custom values live in `useContentEntryDraft`'s `customCells` and save through the same Save / Publish lifecycle as the built-ins.
123123

124124
When the panel is collapsed and an entry is selected, `AdminWorkspaceCanvasLayout` renders a compact notch in the top-right corner of the canvas (`data-testid="content-settings-notch"`) with a button labelled "Open settings panel". Clicking it reopens the panel without changing the selected entry. See [docs/editor.md](../editor.md) — "Admin shell layout" for the notch's implementation context.
125125

@@ -140,7 +140,7 @@ The mode switch is client-only. The markdown body is the source of truth in both
140140
| Hook | Source | Owns |
141141
|------|--------|------|
142142
| `useContentWorkspace` | `hooks/useContentWorkspace.ts` | Collection list, entry list, selection, CRUD operations, error state |
143-
| `useContentEntryDraft` | `hooks/useContentEntryDraft.ts` | In-memory field state (`title`, `body`, `slug`, `featuredMediaId`, `seoTitle`, `seoDescription`), save / publish / status-change handlers |
143+
| `useContentEntryDraft` | `hooks/useContentEntryDraft.ts` | In-memory field state (`title`, `body`, `slug`, `featuredMediaId`, `seoTitle`, `seoDescription`, plus `customCells` for the collection's non-built-in fields), save / publish / status-change handlers |
144144
| `useContentMediaPicker` | `hooks/useContentMediaPicker.ts` | Media picker modal open/close, featured media asset hydration, body media insert |
145145

146146
---

src/admin/pages/content/ContentPage.module.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,26 @@
266266
font-size: var(--text-xs);
267267
}
268268

269+
/* Custom (non-built-in) collection fields, rendered generically via
270+
* CellEditorRenderer. The label style is scoped to the DIRECT child span —
271+
* the cell editors nest their own spans (media filename, relation display)
272+
* that must keep their own styling. */
273+
.customField {
274+
display: grid;
275+
gap: var(--space-s);
276+
}
277+
278+
.customField > span {
279+
color: var(--text-subtle);
280+
font-size: var(--text-xs);
281+
font-weight: 750;
282+
}
283+
284+
.customField > small {
285+
color: var(--text-subtle);
286+
font-size: var(--text-xs);
287+
}
288+
269289
@media (max-width: 720px) {
270290
.documentScroll {
271291
padding: var(--space-11xl) var(--space-2xl) var(--space-9xl);

src/admin/pages/content/ContentPage.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ export function ContentPage() {
267267
...entry,
268268
cells: {
269269
...entry.cells,
270+
...draft.customCells,
270271
body: draft.body,
271272
featuredMedia: draft.featuredMediaId,
272273
seoTitle: draft.seoTitle,
@@ -313,6 +314,7 @@ export function ContentPage() {
313314
...entry,
314315
cells: {
315316
...entry.cells,
317+
...draft.customCells,
316318
body: draft.body,
317319
featuredMedia: draft.featuredMediaId,
318320
seoTitle: draft.seoTitle,
@@ -554,6 +556,7 @@ export function ContentPage() {
554556
authors={workspace.authors}
555557
authorsLoading={workspace.authorsLoading}
556558
collections={workspace.collections}
559+
tables={workspace.tables}
557560
selectedCollection={workspace.selectedCollection}
558561
loading={workspace.contentLoading}
559562
slug={draft.slug}
@@ -566,11 +569,13 @@ export function ContentPage() {
566569
mediaError={mediaPicker.mediaError}
567570
featuredMediaId={draft.featuredMediaId}
568571
featuredMediaAsset={mediaPicker.featuredMediaAsset}
572+
customCells={draft.customCells}
569573
onCollectionChange={(tableId) => void handleMoveEntryCollection(tableId)}
570574
onAuthorChange={(authorUserId) => void handleUpdateEntryAuthor(authorUserId)}
571575
onSlugChange={draft.setSlug}
572576
onSeoTitleChange={draft.setSeoTitle}
573577
onSeoDescriptionChange={draft.setSeoDescription}
578+
onCustomCellChange={draft.setCustomCell}
574579
onStatusChange={(status) => void handleStatusChange(status)}
575580
onChooseFeaturedMedia={() => void mediaPicker.openMediaPicker('featured')}
576581
onClearFeaturedMedia={() => draft.setFeaturedMediaId(null)}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useState } from 'react'
2+
import { CellEditorRenderer } from '@admin/pages/data/components/DataGrid/cells/CellEditorRenderer'
3+
import { RelationPickerDialog } from '@admin/pages/data/components/RelationPickerDialog/RelationPickerDialog'
4+
import { emptyCellValue } from '@admin/pages/data/utils/fieldDefaults'
5+
import type { DataField, DataRowCells, DataTable } from '@core/data/schemas'
6+
import { useRelationTargetRows } from '../../hooks/useRelationTargetRows'
7+
import styles from '../../ContentPage.module.css'
8+
9+
interface ContentCustomFieldsProps {
10+
/** The collection's custom (non-built-in) fields, pre-filtered by the panel. */
11+
fields: DataField[]
12+
entryId: string
13+
/** Every data table (all kinds) — relation custom fields can target any of them. */
14+
tables: DataTable[]
15+
/** Draft values of the custom fields, keyed by field id. */
16+
customCells: DataRowCells
17+
readOnly: boolean
18+
onCustomCellChange: (fieldId: string, value: unknown) => void
19+
}
20+
21+
/**
22+
* Generic editors for a post-type collection's custom fields, rendered at the
23+
* bottom of the Content settings panel. Reuses the Data workspace's per-type
24+
* cell editors (`CellEditorRenderer`, `context="detail"`) so every field type
25+
* behaves identically in both workspaces.
26+
*
27+
* Lazy-loaded by `ContentSettingsPanel` — the cell-editor graph (media picker
28+
* workspace, relation picker) is too heavy for the Content page's initial
29+
* chunk budget.
30+
*/
31+
export function ContentCustomFields({
32+
fields,
33+
entryId,
34+
tables,
35+
customCells,
36+
readOnly,
37+
onCustomCellChange,
38+
}: ContentCustomFieldsProps) {
39+
const [relationPickerFieldId, setRelationPickerFieldId] = useState<string | null>(null)
40+
const resolveRelationRow = useRelationTargetRows(fields)
41+
42+
// Derive relation-picker props from the open field id (same pattern as the
43+
// Data workspace's RowDetail form).
44+
const openRelationField = fields.find((field) => field.id === relationPickerFieldId)
45+
const relationPickerField = openRelationField?.type === 'relation' ? openRelationField : null
46+
const relationPickerTargetTable = relationPickerField
47+
? tables.find((table) => table.id === relationPickerField.targetTableId) ?? null
48+
: null
49+
const relationPickerCurrentValue = relationPickerField
50+
? ((customCells[relationPickerField.id] ?? null) as string | string[] | null)
51+
: null
52+
53+
return (
54+
<>
55+
{fields.map((field) => (
56+
<div key={field.id} className={styles.customField} data-testid={`content-custom-field-${field.id}`}>
57+
<span>{field.label}</span>
58+
{field.description && <small>{field.description}</small>}
59+
<CellEditorRenderer
60+
field={field}
61+
value={customCells[field.id] ?? emptyCellValue(field)}
62+
onChange={(next) => onCustomCellChange(field.id, next)}
63+
context="detail"
64+
readOnly={readOnly}
65+
rowId={entryId}
66+
resolveRelationTarget={resolveRelationRow}
67+
onOpenPicker={
68+
field.type === 'relation'
69+
? () => setRelationPickerFieldId(field.id)
70+
: undefined
71+
}
72+
/>
73+
</div>
74+
))}
75+
76+
<RelationPickerDialog
77+
open={relationPickerField !== null}
78+
onClose={() => setRelationPickerFieldId(null)}
79+
targetTable={relationPickerTargetTable}
80+
currentValue={relationPickerCurrentValue}
81+
allowMultiple={relationPickerField?.allowMultiple ?? false}
82+
onPick={(next) => {
83+
if (relationPickerField) onCustomCellChange(relationPickerField.id, next)
84+
setRelationPickerFieldId(null)
85+
}}
86+
/>
87+
</>
88+
)
89+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* ContentSettingsPanel — custom (non-built-in) field rendering.
3+
*
4+
* Regression for GH-163: fields the user adds to a post type must be
5+
* editable from the Content page's settings sidebar. The built-ins keep
6+
* their dedicated inputs; everything else renders generically through
7+
* `CellEditorRenderer`.
8+
*/
9+
import { afterEach, describe, expect, it, mock } from 'bun:test'
10+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
11+
import { buildPostTypeDefaultFields } from '@core/data/fields'
12+
import type { DataRow, DataTable } from '@core/data/schemas'
13+
import { ContentSettingsPanel } from './ContentSettingsPanel'
14+
15+
afterEach(cleanup)
16+
17+
function fakeCollection(): DataTable {
18+
return {
19+
id: 'tbl_posts',
20+
name: 'Posts',
21+
slug: 'posts',
22+
kind: 'postType',
23+
singularLabel: 'Post',
24+
pluralLabel: 'Posts',
25+
routeBase: '/blog',
26+
primaryFieldId: 'title',
27+
fields: [
28+
...buildPostTypeDefaultFields(),
29+
{ type: 'text', id: 'subtitle', label: 'Subtitle', description: 'Shown under the title' },
30+
{
31+
type: 'select',
32+
id: 'category',
33+
label: 'Category',
34+
options: [{ id: 'opt_news', label: 'News', value: 'news' }],
35+
},
36+
{ type: 'boolean', id: 'featured', label: 'Featured' },
37+
],
38+
system: true,
39+
createdByUserId: null,
40+
updatedByUserId: null,
41+
createdAt: '2026-01-01T00:00:00.000Z',
42+
updatedAt: '2026-01-01T00:00:00.000Z',
43+
}
44+
}
45+
46+
function fakeEntry(): DataRow {
47+
return {
48+
id: 'row_1',
49+
tableId: 'tbl_posts',
50+
cells: { title: 'Hello', slug: 'hello', subtitle: 'World' },
51+
slug: 'hello',
52+
status: 'draft',
53+
authorUserId: null,
54+
createdByUserId: null,
55+
updatedByUserId: null,
56+
publishedByUserId: null,
57+
author: null,
58+
createdBy: null,
59+
updatedBy: null,
60+
publishedBy: null,
61+
createdAt: '2026-01-01T00:00:00.000Z',
62+
updatedAt: '2026-01-01T00:00:00.000Z',
63+
publishedAt: null,
64+
scheduledPublishAt: null,
65+
deletedAt: null,
66+
}
67+
}
68+
69+
async function renderPanel(overrides: Partial<Parameters<typeof ContentSettingsPanel>[0]> = {}) {
70+
const collection = fakeCollection()
71+
const onCustomCellChange = mock(() => {})
72+
render(
73+
<ContentSettingsPanel
74+
selectedEntry={fakeEntry()}
75+
authors={[]}
76+
authorsLoading={false}
77+
collections={[collection]}
78+
tables={[collection]}
79+
selectedCollection={collection}
80+
loading={false}
81+
slug="hello"
82+
slugId="slug-id"
83+
seoTitle=""
84+
seoTitleId="seo-title-id"
85+
seoDescription=""
86+
seoDescriptionId="seo-description-id"
87+
publicPath="/blog/hello"
88+
mediaError={null}
89+
featuredMediaId={null}
90+
featuredMediaAsset={null}
91+
customCells={{ subtitle: 'World' }}
92+
canEditEntry
93+
canMoveEntry
94+
canPublishEntry
95+
canChangeAuthor={false}
96+
onCollectionChange={mock(() => {})}
97+
onAuthorChange={mock(() => {})}
98+
onSlugChange={mock(() => {})}
99+
onSeoTitleChange={mock(() => {})}
100+
onSeoDescriptionChange={mock(() => {})}
101+
onCustomCellChange={onCustomCellChange}
102+
onStatusChange={mock(() => {})}
103+
onChooseFeaturedMedia={mock(() => {})}
104+
onClearFeaturedMedia={mock(() => {})}
105+
onEditFeaturedMedia={mock(() => {})}
106+
{...overrides}
107+
/>,
108+
)
109+
// Wait for the lazy ContentCustomFields chunk to mount, then flush the
110+
// closed RelationPickerDialog's async no-op row load so its setState lands
111+
// inside act() instead of warning after the test body.
112+
await screen.findByTestId('content-custom-field-subtitle')
113+
await act(async () => {})
114+
return { onCustomCellChange }
115+
}
116+
117+
describe('ContentSettingsPanel custom fields', () => {
118+
it('renders an editor for every custom field, none for built-ins', async () => {
119+
await renderPanel()
120+
121+
expect(screen.getByTestId('content-custom-field-subtitle')).toBeTruthy()
122+
expect(screen.getByTestId('content-custom-field-category')).toBeTruthy()
123+
expect(screen.getByTestId('content-custom-field-featured')).toBeTruthy()
124+
// Built-ins keep their dedicated inputs — no generic editor for them.
125+
expect(screen.queryByTestId('content-custom-field-title')).toBeNull()
126+
expect(screen.queryByTestId('content-custom-field-body')).toBeNull()
127+
})
128+
129+
it('shows the draft value and the field description', async () => {
130+
await renderPanel()
131+
132+
const subtitleInput = screen.getByLabelText('Subtitle') as HTMLInputElement
133+
expect(subtitleInput.value).toBe('World')
134+
expect(screen.getByText('Shown under the title')).toBeTruthy()
135+
})
136+
137+
it('propagates edits through onCustomCellChange', async () => {
138+
const { onCustomCellChange } = await renderPanel()
139+
140+
fireEvent.change(screen.getByLabelText('Subtitle'), { target: { value: 'Universe' } })
141+
142+
expect(onCustomCellChange).toHaveBeenCalledWith('subtitle', 'Universe')
143+
})
144+
145+
it('renders custom fields read-only when the user cannot edit the entry', async () => {
146+
await renderPanel({ canEditEntry: false })
147+
148+
const subtitleInput = screen.getByLabelText('Subtitle') as HTMLInputElement
149+
expect(subtitleInput.readOnly).toBe(true)
150+
})
151+
})

0 commit comments

Comments
 (0)