diff --git a/docs/e2e/feature-matrix.md b/docs/e2e/feature-matrix.md index 651a8b38..0fa48bc9 100644 --- a/docs/e2e/feature-matrix.md +++ b/docs/e2e/feature-matrix.md @@ -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/` 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/` 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.}` 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. @@ -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. @@ -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/` 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 | diff --git a/docs/features/content-storage.md b/docs/features/content-storage.md index bb8d4291..40e9235e 100644 --- a/docs/features/content-storage.md +++ b/docs/features/content-storage.md @@ -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 diff --git a/docs/features/content-workspace.md b/docs/features/content-workspace.md index dc75053c..bc2db723 100644 --- a/docs/features/content-workspace.md +++ b/docs/features/content-workspace.md @@ -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. @@ -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 | --- diff --git a/src/admin/pages/content/ContentPage.module.css b/src/admin/pages/content/ContentPage.module.css index 0cbeb656..ef7cd2dd 100644 --- a/src/admin/pages/content/ContentPage.module.css +++ b/src/admin/pages/content/ContentPage.module.css @@ -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); diff --git a/src/admin/pages/content/ContentPage.tsx b/src/admin/pages/content/ContentPage.tsx index b212976e..f4835301 100644 --- a/src/admin/pages/content/ContentPage.tsx +++ b/src/admin/pages/content/ContentPage.tsx @@ -267,6 +267,7 @@ export function ContentPage() { ...entry, cells: { ...entry.cells, + ...draft.customCells, body: draft.body, featuredMedia: draft.featuredMediaId, seoTitle: draft.seoTitle, @@ -313,6 +314,7 @@ export function ContentPage() { ...entry, cells: { ...entry.cells, + ...draft.customCells, body: draft.body, featuredMedia: draft.featuredMediaId, seoTitle: draft.seoTitle, @@ -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} @@ -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)} diff --git a/src/admin/pages/content/components/ContentSettingsPanel/ContentCustomFields.tsx b/src/admin/pages/content/components/ContentSettingsPanel/ContentCustomFields.tsx new file mode 100644 index 00000000..073808db --- /dev/null +++ b/src/admin/pages/content/components/ContentSettingsPanel/ContentCustomFields.tsx @@ -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(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) => ( +
+ {field.label} + {field.description && {field.description}} + onCustomCellChange(field.id, next)} + context="detail" + readOnly={readOnly} + rowId={entryId} + resolveRelationTarget={resolveRelationRow} + onOpenPicker={ + field.type === 'relation' + ? () => setRelationPickerFieldId(field.id) + : undefined + } + /> +
+ ))} + + setRelationPickerFieldId(null)} + targetTable={relationPickerTargetTable} + currentValue={relationPickerCurrentValue} + allowMultiple={relationPickerField?.allowMultiple ?? false} + onPick={(next) => { + if (relationPickerField) onCustomCellChange(relationPickerField.id, next) + setRelationPickerFieldId(null) + }} + /> + + ) +} diff --git a/src/admin/pages/content/components/ContentSettingsPanel/ContentSettingsPanel.test.tsx b/src/admin/pages/content/components/ContentSettingsPanel/ContentSettingsPanel.test.tsx new file mode 100644 index 00000000..4919e488 --- /dev/null +++ b/src/admin/pages/content/components/ContentSettingsPanel/ContentSettingsPanel.test.tsx @@ -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[0]> = {}) { + const collection = fakeCollection() + const onCustomCellChange = mock(() => {}) + render( + {})} + 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) + }) +}) diff --git a/src/admin/pages/content/components/ContentSettingsPanel/ContentSettingsPanel.tsx b/src/admin/pages/content/components/ContentSettingsPanel/ContentSettingsPanel.tsx index c9bc7f8d..bf89524b 100644 --- a/src/admin/pages/content/components/ContentSettingsPanel/ContentSettingsPanel.tsx +++ b/src/admin/pages/content/components/ContentSettingsPanel/ContentSettingsPanel.tsx @@ -1,3 +1,4 @@ +import { Suspense, lazy } from 'react' import { Input, Textarea } from '@ui/components/Input' import { Select } from '@ui/components/Select' import { SkeletonBlock } from '@ui/components/Skeleton' @@ -10,6 +11,8 @@ import { dataTableHasField } from '@core/data/fields' import { POST_TYPE_FIELD_FEATURED_MEDIA, POST_TYPE_FIELD_SEO_TITLE, + type DataField, + type DataRowCells, type DataTable, type DataRow, type DataRowStatus, @@ -19,11 +22,21 @@ import propertiesStyles from '../../../site/panels/PropertiesPanel/PropertiesPan import { PanelHeader } from '@admin/shared/PanelHeader' import styles from '../../ContentPage.module.css' +// Lazy-load the generic custom-field editors: they pull in the Data +// workspace's cell-editor graph (media picker workspace, relation picker), +// which is too heavy for the Content page's initial chunk budget. Only +// collections that actually have custom fields pay for it. +const ContentCustomFields = lazy(() => + import('./ContentCustomFields').then((m) => ({ default: m.ContentCustomFields })), +) + interface ContentSettingsPanelProps { selectedEntry: DataRow | null authors: DataUserReference[] authorsLoading: boolean collections: DataTable[] + /** Every data table (all kinds) — relation custom fields can target any of them. */ + tables: DataTable[] selectedCollection: DataTable | null loading: boolean slug: string @@ -36,6 +49,8 @@ interface ContentSettingsPanelProps { mediaError: string | null featuredMediaId: string | null featuredMediaAsset: CmsMediaAsset | null + /** Draft values of the collection's custom (non-built-in) fields, keyed by field id. */ + customCells: DataRowCells canEditEntry: boolean canMoveEntry: boolean canPublishEntry: boolean @@ -45,6 +60,7 @@ interface ContentSettingsPanelProps { onSlugChange: (value: string) => void onSeoTitleChange: (value: string) => void onSeoDescriptionChange: (value: string) => void + onCustomCellChange: (fieldId: string, value: unknown) => void onStatusChange: (status: DataRowStatus) => void onChooseFeaturedMedia: () => void onClearFeaturedMedia: () => void @@ -75,11 +91,22 @@ function authorOptionLabel(author: DataUserReference): string { return author.displayName || author.email || 'Unknown user' } +/** + * Custom fields the settings panel can edit: everything the user added to + * the collection. `pageTree` / `fieldSchema` cells hold whole documents + * (a node tree / a field array), not values — they have dedicated editors + * and never belong to a generic input. + */ +function isEditableCustomField(field: DataField): boolean { + return field.builtIn !== true && field.type !== 'pageTree' && field.type !== 'fieldSchema' +} + export function ContentSettingsPanel({ selectedEntry, authors, authorsLoading, collections, + tables, selectedCollection, loading, slug, @@ -92,6 +119,7 @@ export function ContentSettingsPanel({ mediaError, featuredMediaId, featuredMediaAsset, + customCells, canEditEntry, canMoveEntry, canPublishEntry, @@ -101,6 +129,7 @@ export function ContentSettingsPanel({ onSlugChange, onSeoTitleChange, onSeoDescriptionChange, + onCustomCellChange, onStatusChange, onChooseFeaturedMedia, onClearFeaturedMedia, @@ -109,6 +138,7 @@ export function ContentSettingsPanel({ const setRightPanel = useWorkspaceLayout((s) => s.setRightPanel) const seoEnabled = selectedCollection ? dataTableHasField(selectedCollection, POST_TYPE_FIELD_SEO_TITLE) : false const featuredMediaEnabled = selectedCollection ? dataTableHasField(selectedCollection, POST_TYPE_FIELD_FEATURED_MEDIA) : false + const customFields = selectedCollection?.fields.filter(isEditableCustomField) ?? [] const authorRoleLabel = selectedEntry ? contentAuthorRoleLabel(selectedEntry) : null const selectedAuthor = selectedEntry ? contentAuthor(selectedEntry) : null const authorOptions = selectedAuthor && !authors.some((author) => author.id === selectedAuthor.id) @@ -258,6 +288,18 @@ export function ContentSettingsPanel({ {mediaError &&

{mediaError}

} )} + {selectedEntry && customFields.length > 0 && ( + + + + )} )} diff --git a/src/admin/pages/content/hooks/__tests__/useContentEntryDraft.test.ts b/src/admin/pages/content/hooks/__tests__/useContentEntryDraft.test.ts new file mode 100644 index 00000000..d38fdedc --- /dev/null +++ b/src/admin/pages/content/hooks/__tests__/useContentEntryDraft.test.ts @@ -0,0 +1,105 @@ +/** + * useContentEntryDraft — custom (non-built-in) field coverage. + * + * Custom fields live in the same `cells_json` record as the built-ins but are + * edited generically through `setCustomCell`. This pins the three behaviors + * the Content settings panel relies on: custom values hydrate into the draft, + * editing one flips `isDirty`, and saving merges them back into the payload + * without clobbering the built-in cells. + */ +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import type { DataRow } from '@core/data/schemas' +import { useContentEntryDraft } from '../useContentEntryDraft' + +afterEach(() => { + cleanup() + mock.restore() +}) + +function fakeRow(cells: DataRow['cells']): DataRow { + return { + id: 'row_1', + tableId: 'tbl_posts', + cells, + slug: typeof cells.slug === 'string' ? cells.slug : '', + 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, + } +} + +function renderDraft(entry: DataRow) { + const updateSelectedEntry = mock(() => {}) + const setError = mock(() => {}) + const rendered = renderHook(() => + useContentEntryDraft({ selectedEntry: entry, updateSelectedEntry, setError }), + ) + return { ...rendered, updateSelectedEntry, setError } +} + +describe('useContentEntryDraft custom cells', () => { + it('hydrates customCells from the entry, excluding built-in field ids', () => { + const { result } = renderDraft(fakeRow({ + title: 'Hello', + slug: 'hello', + body: '# Hello', + subtitle: 'World', + rating: 4, + })) + + expect(result.current.customCells).toEqual({ subtitle: 'World', rating: 4 }) + expect(result.current.isDirty).toBe(false) + }) + + it('marks the draft dirty when a custom cell changes', () => { + const { result } = renderDraft(fakeRow({ title: 'Hello', slug: 'hello', subtitle: 'World' })) + + act(() => result.current.setCustomCell('subtitle', 'Universe')) + + expect(result.current.customCells).toEqual({ subtitle: 'Universe' }) + expect(result.current.isDirty).toBe(true) + }) + + it('saves custom cells merged with the built-in cells', async () => { + const entry = fakeRow({ title: 'Hello', slug: 'hello', body: '# Hello', subtitle: 'World' }) + let patchBody: unknown = null + spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { + patchBody = JSON.parse(String(init?.body)) + const saved = fakeRow({ ...entry.cells, subtitle: 'Universe' }) + return new Response(JSON.stringify({ row: saved }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) + + const { result, updateSelectedEntry } = renderDraft(entry) + act(() => result.current.setCustomCell('subtitle', 'Universe')) + await act(() => result.current.handleSaveDraft()) + + await waitFor(() => expect(result.current.saveMessage).toBe('saved')) + expect(patchBody).toEqual({ + cells: { + title: 'Hello', + slug: 'hello', + body: '# Hello', + subtitle: 'Universe', + featuredMedia: null, + seoTitle: '', + seoDescription: '', + }, + }) + expect(updateSelectedEntry).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/admin/pages/content/hooks/useContentEntryDraft.ts b/src/admin/pages/content/hooks/useContentEntryDraft.ts index 358bac8c..c5ecf523 100644 --- a/src/admin/pages/content/hooks/useContentEntryDraft.ts +++ b/src/admin/pages/content/hooks/useContentEntryDraft.ts @@ -4,7 +4,7 @@ import { saveCmsDataRowDraft, updateCmsDataRowStatus, } from '@core/persistence' -import type { DataRow, DataRowStatus } from '@core/data/schemas' +import type { DataRow, DataRowCells, DataRowStatus } from '@core/data/schemas' import { readBodyCell, readFeaturedMediaCell, @@ -12,6 +12,7 @@ import { readSeoTitleCell, readSlugCell, readTitleCell, + stripPostTypeBuiltInCells, } from '@core/data/cells' import { slugFromTitle } from '@core/utils/slug' import { getErrorMessage } from '@core/utils/errorMessage' @@ -44,6 +45,10 @@ export function useContentEntryDraft({ const [seoDescription, setSeoDescription] = useState('') const [featuredMediaId, setFeaturedMediaId] = useState(null) const [body, setBody] = useState('') + // Values of the collection's CUSTOM (non-built-in) fields, keyed by field + // id — edited generically in the Content settings panel and saved through + // the same draft lifecycle as the built-ins above. + const [customCells, setCustomCells] = useState({}) const [saveMessage, setSaveMessage] = useState('idle') // Exception #1: referenced in the useLayoutEffect dep array below, so it @@ -55,9 +60,14 @@ export function useContentEntryDraft({ setSeoDescription(entry ? readSeoDescriptionCell(entry.cells) : '') setFeaturedMediaId(entry ? readFeaturedMediaCell(entry.cells) : null) setBody(entry ? readBodyCell(entry.cells) : '') + setCustomCells(entry ? stripPostTypeBuiltInCells(entry.cells) : {}) setSaveMessage('idle') }, []) + const setCustomCell = (fieldId: string, value: unknown) => { + setCustomCells((cells) => ({ ...cells, [fieldId]: value })) + } + /* eslint-disable react-hooks/set-state-in-effect, react-hooks/exhaustive-deps */ useLayoutEffect(() => { applySelectedEntry(selectedEntry) @@ -70,6 +80,7 @@ export function useContentEntryDraft({ setSeoTitle(readSeoTitleCell(entry.cells)) setSeoDescription(readSeoDescriptionCell(entry.cells)) setFeaturedMediaId(readFeaturedMediaCell(entry.cells)) + setCustomCells(stripPostTypeBuiltInCells(entry.cells)) } const isDirty = (() => { @@ -79,7 +90,10 @@ export function useContentEntryDraft({ seoTitle !== readSeoTitleCell(selectedEntry.cells) || seoDescription !== readSeoDescriptionCell(selectedEntry.cells) || featuredMediaId !== readFeaturedMediaCell(selectedEntry.cells) || - body !== readBodyCell(selectedEntry.cells) + body !== readBodyCell(selectedEntry.cells) || + // Cell values are JSON by definition (persisted via cells_json), so a + // stringify comparison is exact — same approach as useDataRowDraft. + JSON.stringify(customCells) !== JSON.stringify(stripPostTypeBuiltInCells(selectedEntry.cells)) })() const saveDraft = async (): Promise => { @@ -89,6 +103,7 @@ export function useContentEntryDraft({ const row = await saveCmsDataRowDraft(selectedEntry.id, { cells: { ...selectedEntry.cells, + ...customCells, title: nextTitle, slug: nextSlug, body, @@ -174,6 +189,7 @@ export function useContentEntryDraft({ seoDescription, featuredMediaId, body, + customCells, isDirty, saveMessage, setTitle, @@ -182,6 +198,7 @@ export function useContentEntryDraft({ setSeoDescription, setFeaturedMediaId, setBody, + setCustomCell, setSaveMessage, handleSaveDraft, handlePublish, diff --git a/src/admin/pages/content/hooks/useContentWorkspace.ts b/src/admin/pages/content/hooks/useContentWorkspace.ts index b19793d9..9ceefbd0 100644 --- a/src/admin/pages/content/hooks/useContentWorkspace.ts +++ b/src/admin/pages/content/hooks/useContentWorkspace.ts @@ -41,6 +41,9 @@ export function useContentWorkspace({ loadAuthors: shouldLoadAuthors = true, }: UseContentWorkspaceOptions = {}) { const setRightPanel = useWorkspaceLayout((s) => s.setRightPanel) + // Every data table (all kinds) — relation custom fields can target any + // table, so the settings panel needs the full list to resolve targets. + const [tables, setTables] = useState([]) const [collections, setCollections] = useState([]) const [entries, setEntries] = useState([]) const [authors, setAuthors] = useState([]) @@ -117,6 +120,7 @@ export function useContentWorkspace({ const nextCollections = allTables.filter((table) => table.kind === 'postType') if (cancelled) return const fallbackCollectionId = nextCollections[0]?.id ?? null + setTables(allTables) setCollections(nextCollections) setEntriesLoading(Boolean(fallbackCollectionId)) setSelectedCollectionId((current) => current ?? fallbackCollectionId) @@ -281,6 +285,7 @@ export function useContentWorkspace({ setEntriesLoading(true) // Always create post-type tables from the Content page. const collection = await createCmsDataTable({ ...input, kind: 'postType' }) + setTables((current) => [...current, collection]) setCollections((current) => [...current, collection]) setEntries([]) setSelectedCollectionId(collection.id) @@ -294,6 +299,9 @@ export function useContentWorkspace({ ) => { setError(null) const collection = await updateCmsDataTable(tableId, input) + setTables((current) => current.map((candidate) => + candidate.id === collection.id ? collection : candidate + )) setCollections((current) => current.map((candidate) => candidate.id === collection.id ? collection : candidate )) @@ -304,6 +312,7 @@ export function useContentWorkspace({ setError(null) await deleteCmsDataTable(tableId) + setTables((current) => current.filter((table) => table.id !== tableId)) const nextCollections = collections.filter((collection) => collection.id !== tableId) const nextSelectedCollectionId = selectedCollectionId === tableId ? nextCollections[0]?.id ?? null @@ -421,6 +430,7 @@ export function useContentWorkspace({ } return { + tables, collections, entries, authors, diff --git a/src/admin/pages/content/hooks/useRelationTargetRows.ts b/src/admin/pages/content/hooks/useRelationTargetRows.ts new file mode 100644 index 00000000..6175963e --- /dev/null +++ b/src/admin/pages/content/hooks/useRelationTargetRows.ts @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react' +import { listCmsDataRows } from '@core/persistence' +import type { DataField, DataRow } from '@core/data/schemas' + +/** + * Resolves relation-field target rows for display in the Content settings + * panel. Fetches the rows of every DISTINCT table targeted by a relation + * field once, then answers `resolveRow(rowId)` lookups synchronously — the + * same contract `RelationCell` consumes via `resolveRelationTarget`. + * + * Fetch failures are non-fatal: an unresolved id falls back to raw-id + * display in the cell. + */ +export function useRelationTargetRows(fields: DataField[]): (rowId: string) => DataRow | null { + const [rowsByTable, setRowsByTable] = useState>(new Map()) + + // Stable key over the SET of target tables — sorted so field order and + // identity churn don't retrigger the fetch effect. + const targetKey = [...new Set( + fields.flatMap((field) => (field.type === 'relation' ? [field.targetTableId] : [])), + )].sort().join('\0') + + // Render-time reset when the target-table set changes (the React-recommended + // adjust-state-during-render pattern) — the effect below only fetches. + const [prevTargetKey, setPrevTargetKey] = useState(targetKey) + if (prevTargetKey !== targetKey) { + setPrevTargetKey(targetKey) + setRowsByTable(new Map()) + } + + useEffect(() => { + if (targetKey === '') return + let cancelled = false + const tableIds = targetKey.split('\0') + void Promise.all( + tableIds.map(async (tableId) => { + // Non-fatal: a missing / forbidden target table resolves to no rows. + const rows = await listCmsDataRows(tableId).catch(() => [] as DataRow[]) + return [tableId, rows] as const + }), + ).then((pairs) => { + if (!cancelled) setRowsByTable(new Map(pairs)) + }) + return () => { cancelled = true } + }, [targetKey]) + + return (rowId: string) => { + for (const rows of rowsByTable.values()) { + const match = rows.find((row) => row.id === rowId) + if (match) return match + } + return null + } +} diff --git a/src/core/data/__tests__/cells.test.ts b/src/core/data/__tests__/cells.test.ts new file mode 100644 index 00000000..bece8ab5 --- /dev/null +++ b/src/core/data/__tests__/cells.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'bun:test' +import { stripPostTypeBuiltInCells } from '../cells' + +describe('stripPostTypeBuiltInCells', () => { + it('drops the six post-type built-in field ids and keeps custom cells', () => { + const cells = { + title: 'Hello', + slug: 'hello', + body: '# Hello', + featuredMedia: 'media_1', + seoTitle: 'Hello — SEO', + seoDescription: 'A description', + subtitle: 'World', + rating: 4, + tags: ['a', 'b'], + } + + expect(stripPostTypeBuiltInCells(cells)).toEqual({ + subtitle: 'World', + rating: 4, + tags: ['a', 'b'], + }) + }) + + it('returns an empty record when the row only has built-in cells', () => { + expect(stripPostTypeBuiltInCells({ title: 'Hello', slug: 'hello' })).toEqual({}) + }) + + it('returns an empty record for empty cells', () => { + expect(stripPostTypeBuiltInCells({})).toEqual({}) + }) +}) diff --git a/src/core/data/cells.ts b/src/core/data/cells.ts index a97e8c67..f84c3d9a 100644 --- a/src/core/data/cells.ts +++ b/src/core/data/cells.ts @@ -10,7 +10,7 @@ import { NodeTreeSchema, type NodeTree } from '@core/page-tree' import type { BaseNode } from '@core/page-tree' import { DataFieldSchema, type DataField, type DataRowCells, type DataTable } from './schemas' -import { dataTableHasField } from './fields' +import { dataTableHasField, isPostTypeBuiltInFieldId } from './fields' import { slugFromTitle } from '@core/utils/slug' import { safeParseValue } from '@core/utils/typeboxHelpers' @@ -80,6 +80,18 @@ export function readSeoDescriptionCell(cells: DataRowCells): string { return readStringCell(cells, 'seoDescription') } +/** + * The subset of a row's cells that belongs to CUSTOM (non-built-in) fields. + * The Content authoring UI edits the post-type built-ins through dedicated + * inputs (title, slug, body, featured media, SEO); everything else is a + * user-defined field edited generically. Key order follows the input record. + */ +export function stripPostTypeBuiltInCells(cells: DataRowCells): DataRowCells { + return Object.fromEntries( + Object.entries(cells).filter(([fieldId]) => !isPostTypeBuiltInFieldId(fieldId)), + ) +} + /** * Read a `pageTree` cell value. Returns the validated `NodeTree` or null if * the cell is missing or does not conform to the expected shape. diff --git a/tests/e2e/content.e2e.ts b/tests/e2e/content.e2e.ts index b09376bf..8fde3181 100644 --- a/tests/e2e/content.e2e.ts +++ b/tests/e2e/content.e2e.ts @@ -2,16 +2,21 @@ import { expect, test, type Page } from '@playwright/test' import { ANONYMOUS_STATE, OWNER, + canvasFrame, completeStepUp, + insertNotchModule, login, openSiteEditor, + openSitePanel, publishDraft, + saveDraft, + setPropValue, visitPublicPage, } from './helpers' /** * CONTENT-001 / CONTENT-002 / CONTENT-003 / CONTENT-005 / CONTENT-006 / - * CONTENT-007 — + * CONTENT-007 / CONTENT-008 — * create content entries, publish them, edit rich bodies, preview drafts inside * templates, manage collection-level field settings, and surface content AI * setup guidance. @@ -356,6 +361,83 @@ test.describe('content', () => { await expect(settingsPanel.getByLabel('SEO title')).toHaveCount(0) }) }) + + test('edits a custom Posts field and renders it through a Site template binding (CONTENT-008)', async ({ + page, + browser, + }) => { + await login(page) + const suffix = Date.now().toString(36) + const fieldId = `e2e_subtitle_${suffix}` + const fieldLabel = `E2E subtitle ${suffix}` + const postTitle = `E2E Custom Field ${suffix}` + const postSlug = `e2e-custom-field-${suffix}` + const bodyText = `Body for custom field token ${suffix}` + const customValue = `Custom field value ${suffix}` + const templateName = `Custom Field Template ${suffix}` + const templateSlug = `custom-field-template-${suffix}` + const tokenText = `Custom subtitle: {currentEntry.${fieldId}}` + const renderedText = `Custom subtitle: ${customValue}` + + await test.step('add a custom text field to the system Posts table', async () => { + await addPostsTextField(page, fieldId, fieldLabel) + }) + + await test.step('save the custom field value from the Content settings panel', async () => { + await createPostDraftWithSlug(page, postTitle, postSlug, bodyText) + + const settingsPanel = page.getByTestId('content-settings-panel') + await expect(settingsPanel.getByTestId(`content-custom-field-${fieldId}`)).toBeVisible() + await settingsPanel.getByLabel(fieldLabel).fill(customValue) + await saveSelectedDraft(page, postTitle) + }) + + await test.step('reload Content and verify the custom field value persists', async () => { + await page.reload() + await expect(entryRow(page, postTitle)).toBeVisible() + await entryRow(page, postTitle).click() + + const settingsPanel = page.getByTestId('content-settings-panel') + await expect(settingsPanel.getByLabel(fieldLabel)).toHaveValue(customValue) + }) + + await test.step('publish the post so template preview and public routes use saved row data', async () => { + await page.getByRole('button', { name: 'Publish post' }).click() + await completeStepUp(page) + await expect( + page.getByRole('button', { name: 'Published', exact: true }), + ).toBeDisabled({ timeout: 20_000 }) + }) + + await test.step('insert the custom field from the Site builder binding picker', async () => { + await createPostsTemplate(page, templateName, templateSlug) + await insertNotchModule(page, 'text') + await setPropValue(page, 'text', 'Custom subtitle:') + await page.getByRole('button', { name: 'Insert binding for Text' }).click() + + const bindingMenu = page.getByRole('menu', { name: 'Insert binding for Text' }) + await expect(bindingMenu.getByLabel('Scoped to Posts')).toContainText(/Current row\s+.\s+Posts/) + const customFieldOption = bindingMenu.getByRole('button').filter({ hasText: fieldLabel }).first() + await expect(customFieldOption).toBeVisible() + await customFieldOption.click() + await page.keyboard.press('Escape') + + await expect(page.locator('#ctrl-text')).toHaveValue(tokenText) + await expect(canvasFrame(page).getByText(renderedText, { exact: true })).toBeVisible({ + timeout: 20_000, + }) + }) + + await test.step('publish the template and verify the anonymous public post route resolves the custom token', async () => { + await saveDraft(page) + await publishDraft(page) + await visitPublicPage(browser, { + path: `/posts/${postSlug}`, + visibleText: [renderedText], + hiddenText: [tokenText, `{currentEntry.${fieldId}}`, templateName], + }) + }) + }) }) }) @@ -387,6 +469,80 @@ async function createPostDraft( }) } +async function createPostDraftWithSlug( + page: Page, + title: string, + slug: string, + body: string, +): Promise { + await page.goto('/admin/content') + + await test.step('create a new post with a stable public slug', async () => { + const newPost = page.getByRole('button', { name: 'New post', exact: true }) + await expect(newPost).toBeEnabled() + await newPost.click() + + await page.getByRole('textbox', { name: 'Title', exact: true }).fill(title) + await page.getByRole('textbox', { name: 'Slug' }).fill(slug) + await page.getByTestId('content-body-editor').click() + await page.keyboard.type(body) + }) +} + +async function addPostsTextField( + page: Page, + fieldId: string, + fieldLabel: string, +): Promise { + await page.goto('/admin/data') + await expect(page.getByTestId('data-left-sidebar')).toBeVisible({ timeout: 20_000 }) + + await page.getByRole('option', { name: /^Posts\b/ }).click() + const inspector = page.getByTestId('data-inspector-panel') + await expect(inspector).toBeVisible() + + const fieldsToggle = inspector.getByRole('button', { name: 'Fields' }) + if ((await fieldsToggle.getAttribute('aria-expanded')) !== 'true') { + await fieldsToggle.click() + } + await inspector.getByRole('button', { name: 'Add field' }).click() + + const dialog = page.getByRole('dialog', { name: 'New field' }) + await expect(dialog).toBeVisible() + await dialog.getByLabel('ID').fill(fieldId) + await dialog.getByLabel('Label').fill(fieldLabel) + await dialog.getByRole('button', { name: 'Create' }).click() + await completeStepUp(page) + + await expect(dialog).toBeHidden({ timeout: 20_000 }) + await expect(inspector.getByRole('button', { name: `Edit ${fieldLabel}` })).toBeVisible({ + timeout: 20_000, + }) +} + +async function createPostsTemplate( + page: Page, + templateName: string, + templateSlug: string, +): Promise { + await openSiteEditor(page) + await openSitePanel(page) + await page.getByRole('button', { name: 'New template', exact: true }).click() + + const dialog = page.getByRole('dialog', { name: 'Template settings' }) + await expect(dialog).toBeVisible() + await dialog.getByLabel('Name').fill(templateName) + await dialog.getByLabel('Slug').fill(templateSlug) + await dialog.getByLabel('Applies to').click() + await page.getByRole('option', { name: 'Post types' }).click() + await dialog.getByLabel('Posts').setChecked(true) + await dialog.getByLabel('Priority').fill('260') + await dialog.getByRole('button', { name: 'Save' }).click() + + await expect(dialog).toBeHidden() + await expect(page.getByTestId('document-switcher')).toHaveAttribute('placeholder', templateName) +} + async function saveSelectedDraft(page: Page, title: string): Promise { await page.getByRole('button', { name: 'More publishing actions' }).click() await page.getByTestId('toolbar-content-save-draft-action').click()