Skip to content
Draft
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
5 changes: 4 additions & 1 deletion docs/e2e/feature-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ BUILDER-008 note: rich-body bold/italic persistence and public entry-template re

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.

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.
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.

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.

Expand Down Expand Up @@ -249,6 +249,7 @@ MEDIA-007 note: unsafe SVG upload sanitization and public `/uploads` serving are
| 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 |
| 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 |
| 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 |
| 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 |

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.

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

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.

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.

## AI Workspace

| ID | Priority | Auto | Area | User Goal | Setup | Path | Expected Outcome | Watch For |
Expand Down
2 changes: 1 addition & 1 deletion docs/features/content-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ For **post-types**, public row routes require an explicitly authored entry templ
2. Add a field (pick a type from `DataFieldType`).
3. Optionally mark it `required` or set a `defaultValue`.

The field appears in the postType's edit form and is queryable from loops.
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.

### Create a custom post-type

Expand Down
4 changes: 2 additions & 2 deletions docs/features/content-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Collections are `data_tables` with `kind: 'postType'`. The four system tables (`
`AdminWorkspaceCanvasLayout` treats an absent `contentRightPanel` as "no right panel available",
so a persisted open state never reserves an empty right rail on a fresh install or after the last entry is cleared.

The panel exposes: status selector, slug, author (if the user has `canEditAnyContent`), collection (move entry), SEO title, SEO description, featured media.
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.

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.

Expand All @@ -139,7 +139,7 @@ The mode switch is client-only. The markdown body is the source of truth in both
| Hook | Source | Owns |
|------|--------|------|
| `useContentWorkspace` | `hooks/useContentWorkspace.ts` | Collection list, entry list, selection, CRUD operations, error state |
| `useContentEntryDraft` | `hooks/useContentEntryDraft.ts` | In-memory field state (`title`, `body`, `slug`, `featuredMediaId`, `seoTitle`, `seoDescription`), save / publish / status-change handlers |
| `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 |
| `useContentMediaPicker` | `hooks/useContentMediaPicker.ts` | Media picker modal open/close, featured media asset hydration, body media insert |

---
Expand Down
20 changes: 20 additions & 0 deletions src/admin/pages/content/ContentPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,26 @@
font-size: var(--text-xs);
}

/* Custom (non-built-in) collection fields, rendered generically via
* CellEditorRenderer. The label style is scoped to the DIRECT child span —
* the cell editors nest their own spans (media filename, relation display)
* that must keep their own styling. */
.customField {
display: grid;
gap: var(--space-s);
}

.customField > span {
color: var(--text-subtle);
font-size: var(--text-xs);
font-weight: 750;
}

.customField > small {
color: var(--text-subtle);
font-size: var(--text-xs);
}

@media (max-width: 720px) {
.documentScroll {
padding: var(--space-11xl) var(--space-2xl) var(--space-9xl);
Expand Down
5 changes: 5 additions & 0 deletions src/admin/pages/content/ContentPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ export function ContentPage() {
...entry,
cells: {
...entry.cells,
...draft.customCells,
body: draft.body,
featuredMedia: draft.featuredMediaId,
seoTitle: draft.seoTitle,
Expand Down Expand Up @@ -313,6 +314,7 @@ export function ContentPage() {
...entry,
cells: {
...entry.cells,
...draft.customCells,
body: draft.body,
featuredMedia: draft.featuredMediaId,
seoTitle: draft.seoTitle,
Expand Down Expand Up @@ -554,6 +556,7 @@ export function ContentPage() {
authors={workspace.authors}
authorsLoading={workspace.authorsLoading}
collections={workspace.collections}
tables={workspace.tables}
selectedCollection={workspace.selectedCollection}
loading={workspace.contentLoading}
slug={draft.slug}
Expand All @@ -566,11 +569,13 @@ export function ContentPage() {
mediaError={mediaPicker.mediaError}
featuredMediaId={draft.featuredMediaId}
featuredMediaAsset={mediaPicker.featuredMediaAsset}
customCells={draft.customCells}
onCollectionChange={(tableId) => void handleMoveEntryCollection(tableId)}
onAuthorChange={(authorUserId) => void handleUpdateEntryAuthor(authorUserId)}
onSlugChange={draft.setSlug}
onSeoTitleChange={draft.setSeoTitle}
onSeoDescriptionChange={draft.setSeoDescription}
onCustomCellChange={draft.setCustomCell}
onStatusChange={(status) => void handleStatusChange(status)}
onChooseFeaturedMedia={() => void mediaPicker.openMediaPicker('featured')}
onClearFeaturedMedia={() => draft.setFeaturedMediaId(null)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useState } from 'react'
import { CellEditorRenderer } from '@admin/pages/data/components/DataGrid/cells/CellEditorRenderer'
import { RelationPickerDialog } from '@admin/pages/data/components/RelationPickerDialog/RelationPickerDialog'
import { emptyCellValue } from '@admin/pages/data/utils/fieldDefaults'
import type { DataField, DataRowCells, DataTable } from '@core/data/schemas'
import { useRelationTargetRows } from '../../hooks/useRelationTargetRows'
import styles from '../../ContentPage.module.css'

interface ContentCustomFieldsProps {
/** The collection's custom (non-built-in) fields, pre-filtered by the panel. */
fields: DataField[]
entryId: string
/** Every data table (all kinds) — relation custom fields can target any of them. */
tables: DataTable[]
/** Draft values of the custom fields, keyed by field id. */
customCells: DataRowCells
readOnly: boolean
onCustomCellChange: (fieldId: string, value: unknown) => void
}

/**
* Generic editors for a post-type collection's custom fields, rendered at the
* bottom of the Content settings panel. Reuses the Data workspace's per-type
* cell editors (`CellEditorRenderer`, `context="detail"`) so every field type
* behaves identically in both workspaces.
*
* Lazy-loaded by `ContentSettingsPanel` — the cell-editor graph (media picker
* workspace, relation picker) is too heavy for the Content page's initial
* chunk budget.
*/
export function ContentCustomFields({
fields,
entryId,
tables,
customCells,
readOnly,
onCustomCellChange,
}: ContentCustomFieldsProps) {
const [relationPickerFieldId, setRelationPickerFieldId] = useState<string | null>(null)
const resolveRelationRow = useRelationTargetRows(fields)

// Derive relation-picker props from the open field id (same pattern as the
// Data workspace's RowDetail form).
const openRelationField = fields.find((field) => field.id === relationPickerFieldId)
const relationPickerField = openRelationField?.type === 'relation' ? openRelationField : null
const relationPickerTargetTable = relationPickerField
? tables.find((table) => table.id === relationPickerField.targetTableId) ?? null
: null
const relationPickerCurrentValue = relationPickerField
? ((customCells[relationPickerField.id] ?? null) as string | string[] | null)
: null

return (
<>
{fields.map((field) => (
<div key={field.id} className={styles.customField} data-testid={`content-custom-field-${field.id}`}>
<span>{field.label}</span>
{field.description && <small>{field.description}</small>}
<CellEditorRenderer
field={field}
value={customCells[field.id] ?? emptyCellValue(field)}
onChange={(next) => onCustomCellChange(field.id, next)}
context="detail"
readOnly={readOnly}
rowId={entryId}
resolveRelationTarget={resolveRelationRow}
onOpenPicker={
field.type === 'relation'
? () => setRelationPickerFieldId(field.id)
: undefined
}
/>
</div>
))}

<RelationPickerDialog
open={relationPickerField !== null}
onClose={() => setRelationPickerFieldId(null)}
targetTable={relationPickerTargetTable}
currentValue={relationPickerCurrentValue}
allowMultiple={relationPickerField?.allowMultiple ?? false}
onPick={(next) => {
if (relationPickerField) onCustomCellChange(relationPickerField.id, next)
setRelationPickerFieldId(null)
}}
/>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* ContentSettingsPanel — custom (non-built-in) field rendering.
*
* Regression for GH-163: fields the user adds to a post type must be
* editable from the Content page's settings sidebar. The built-ins keep
* their dedicated inputs; everything else renders generically through
* `CellEditorRenderer`.
*/
import { afterEach, describe, expect, it, mock } from 'bun:test'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { buildPostTypeDefaultFields } from '@core/data/fields'
import type { DataRow, DataTable } from '@core/data/schemas'
import { ContentSettingsPanel } from './ContentSettingsPanel'

afterEach(cleanup)

function fakeCollection(): DataTable {
return {
id: 'tbl_posts',
name: 'Posts',
slug: 'posts',
kind: 'postType',
singularLabel: 'Post',
pluralLabel: 'Posts',
routeBase: '/blog',
primaryFieldId: 'title',
fields: [
...buildPostTypeDefaultFields(),
{ type: 'text', id: 'subtitle', label: 'Subtitle', description: 'Shown under the title' },
{
type: 'select',
id: 'category',
label: 'Category',
options: [{ id: 'opt_news', label: 'News', value: 'news' }],
},
{ type: 'boolean', id: 'featured', label: 'Featured' },
],
system: true,
createdByUserId: null,
updatedByUserId: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
}
}

function fakeEntry(): DataRow {
return {
id: 'row_1',
tableId: 'tbl_posts',
cells: { title: 'Hello', slug: 'hello', subtitle: 'World' },
slug: 'hello',
status: 'draft',
authorUserId: null,
createdByUserId: null,
updatedByUserId: null,
publishedByUserId: null,
author: null,
createdBy: null,
updatedBy: null,
publishedBy: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
publishedAt: null,
scheduledPublishAt: null,
deletedAt: null,
}
}

async function renderPanel(overrides: Partial<Parameters<typeof ContentSettingsPanel>[0]> = {}) {
const collection = fakeCollection()
const onCustomCellChange = mock(() => {})
render(
<ContentSettingsPanel
selectedEntry={fakeEntry()}
authors={[]}
authorsLoading={false}
collections={[collection]}
tables={[collection]}
selectedCollection={collection}
loading={false}
slug="hello"
slugId="slug-id"
seoTitle=""
seoTitleId="seo-title-id"
seoDescription=""
seoDescriptionId="seo-description-id"
publicPath="/blog/hello"
mediaError={null}
featuredMediaId={null}
featuredMediaAsset={null}
customCells={{ subtitle: 'World' }}
canEditEntry
canMoveEntry
canPublishEntry
canChangeAuthor={false}
onCollectionChange={mock(() => {})}
onAuthorChange={mock(() => {})}
onSlugChange={mock(() => {})}
onSeoTitleChange={mock(() => {})}
onSeoDescriptionChange={mock(() => {})}
onCustomCellChange={onCustomCellChange}
onStatusChange={mock(() => {})}
onChooseFeaturedMedia={mock(() => {})}
onClearFeaturedMedia={mock(() => {})}
onEditFeaturedMedia={mock(() => {})}
{...overrides}
/>,
)
// Wait for the lazy ContentCustomFields chunk to mount, then flush the
// closed RelationPickerDialog's async no-op row load so its setState lands
// inside act() instead of warning after the test body.
await screen.findByTestId('content-custom-field-subtitle')
await act(async () => {})
return { onCustomCellChange }
}

describe('ContentSettingsPanel custom fields', () => {
it('renders an editor for every custom field, none for built-ins', async () => {
await renderPanel()

expect(screen.getByTestId('content-custom-field-subtitle')).toBeTruthy()
expect(screen.getByTestId('content-custom-field-category')).toBeTruthy()
expect(screen.getByTestId('content-custom-field-featured')).toBeTruthy()
// Built-ins keep their dedicated inputs — no generic editor for them.
expect(screen.queryByTestId('content-custom-field-title')).toBeNull()
expect(screen.queryByTestId('content-custom-field-body')).toBeNull()
})

it('shows the draft value and the field description', async () => {
await renderPanel()

const subtitleInput = screen.getByLabelText('Subtitle') as HTMLInputElement
expect(subtitleInput.value).toBe('World')
expect(screen.getByText('Shown under the title')).toBeTruthy()
})

it('propagates edits through onCustomCellChange', async () => {
const { onCustomCellChange } = await renderPanel()

fireEvent.change(screen.getByLabelText('Subtitle'), { target: { value: 'Universe' } })

expect(onCustomCellChange).toHaveBeenCalledWith('subtitle', 'Universe')
})

it('renders custom fields read-only when the user cannot edit the entry', async () => {
await renderPanel({ canEditEntry: false })

const subtitleInput = screen.getByLabelText('Subtitle') as HTMLInputElement
expect(subtitleInput.readOnly).toBe(true)
})
})
Loading