Skip to content
Merged
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
Binary file added docs/assets/pr/data-workspace-publishing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/pr/data-workspace-schema-builder.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions docs/features/content-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ Users can add their own custom fields to system tables.
| `email` | `string \| null` | |
| `media` | single: `mediaId \| null`; multi: `string[]` | References `media_assets` |
| `relation` | single: `rowId \| null`; multi: `string[]`| Relates to rows in another `data_table` |
| `repeater` | ordered `{ id, cells }[]` | One-level structured item collection |
| `pageTree` | `NodeTree<PageNode>` JSON | The visual tree (pages, VC trees) |
| `fieldSchema` | JSON describing fields | Used by VCs to declare `params` |

Expand All @@ -130,6 +131,7 @@ readStringCell(cells, 'title') // → string ('' fallback)
readNumberCell(cells, 'price') // → number | null
readBooleanCell(cells, 'featured') // → boolean
readStringArrayCell(cells, 'tags') // → string[]
readRepeaterCell(cells, 'gallery') // → { id, cells }[]
readTitleCell(cells) // → string (reads 'title')
readSlugCell(cells) // → string (reads 'slug')
readBodyCell(cells) // → string (reads 'body')
Expand All @@ -139,6 +141,11 @@ readNodeTreeCell(cells, 'body') // → NodeTree<PageNode> | null
readFieldSchemaCell(cells, 'params') // → DataField[] | null
```

Repeater definitions store their item schema in `field.fields`.
`RepeaterItemFieldSchema` excludes `repeater`, `pageTree`, and `fieldSchema`,
so v1 repeaters are one level deep. Stable item ids make reorder and duplicate
operations deterministic; nested values remain keyed by their item field ids.

These do the boundary validation — handlers and modules read through them rather than typing `cells.foo as string`.

To compute the denormalized, URL-normalized slug for a row (empty string when the table has no `slug` field):
Expand Down
70 changes: 43 additions & 27 deletions docs/features/data-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ The Data workspace lets operators define and edit table schemas (field types, ro
- **DataGrid:** read-only spreadsheet over `data_rows` — cells display via `CellDisplayRenderer`, editing opens in the inspector. Owns search, status filter, sort, selection, group collapse, and column resize state. Sub-components handle toolbar, header row, group headers, skeleton loading, empty state, and bulk actions.
- **DataInspector:** right panel — switches between `RowDetail` (cell editor) and `TableSettings` (schema editor) based on row selection.
- **Context menus:** `DataTableContextMenu` handles table-list actions; `DataRowContextMenu` handles grid-row actions. Both use the shared `ContextMenu` primitive.
- **TableSettings** owns field management via `FieldsSection`, which is split into `FieldRow`, `FieldEditForm`, `fieldGuards`, and `fieldEditState`.
- **NewTableDialog** creates collection identity and its full field schema in one atomic request.
- **FieldSchemaComposer** is shared by new-table setup and `TableSettings`; `NewFieldDialog` also uses the same definitions for repeater item schemas.
- **TableSettings** adapts persisted-table locks and warnings into the shared composer through `FieldsSection`.
- Field classification: three tiers — mandatory built-ins (locked), optional built-ins (editable/deletable with badge), custom fields (fully editable/deletable).
- Field edit state uses a flat `FieldEditState` draft that `fieldToEditState` / `applyEditState` convert to/from the persisted `DataField`.
- `RepeaterCell` authors ordered structured items with add, duplicate, reorder, and delete actions. A media-only repeater delegates to `MediaRepeaterGallery`, which reuses the Media workspace asset rows/tiles and shared grid/list preference.
- Mutations to system `page` and `component` rows request a retained Site-editor reload through `requestCmsSiteReload()` so `/admin/site` sees Data-created pages and Visual Components even when the editor store was already hydrated.

---
Expand Down Expand Up @@ -45,11 +47,13 @@ DataPage.tsx
└── DataInspector.tsx ← right-hand inspector panel
├── RowDetail.tsx ← row selected: cell-by-cell editor
└── TableSettings.tsx ← no row selected: schema + metadata editor
└── FieldsSection.tsx ← field list: DnD reorder, inline edit, delete, add
├── FieldRow.tsx ← presentational field row
├── FieldEditForm.tsx ← inline field edit form
├── fieldGuards.ts ← pure field classification
└── fieldEditState.ts ← draft state shape + conversions
└── FieldsSection.tsx ← persisted-table adapter
└── FieldSchemaComposer

NewTableDialog.tsx
└── FieldSchemaComposer ← local schema draft submitted with table identity
├── FieldRow.tsx ← ordered field summary
└── NewFieldDialog.tsx ← add/edit definition and repeater sub-fields
```

---
Expand All @@ -71,19 +75,28 @@ DataPage.tsx

## TableSettings and field management

`TableSettings.tsx` renders collapsible sections (General, Routing, Display, Fields, Kind, Danger zone). The **Fields** section delegates to `FieldsSection`.
`TableSettings.tsx` renders collapsible sections (General, Routing, Schema, Kind, Danger zone). The **Schema** section delegates to `FieldsSection`; each eligible field row carries a star action for assigning the primary field directly where the structure is defined.

**System tables** (`posts`/`pages`/`components`/`layouts`) render a **reduced** panel: only **Display** (primary field) and **Fields** are shown, gated by `data.system.tables.manage`. General / Routing / Kind / Danger zone are hidden because a system table's identity is frozen (the server's `assertSystemTableUpdateAllowed` rejects identity + built-in-field changes for everyone). Managers can still add/manage **custom** fields and change the primary field.
**System tables** (`posts`/`pages`/`components`/`layouts`) render a **reduced** panel containing only **Schema**, gated by `data.system.tables.manage`. General / Routing / Kind / Danger zone are hidden because a system table's identity is frozen (the server's `assertSystemTableUpdateAllowed` rejects identity + built-in-field changes for everyone). Managers can still add/manage **custom** fields and change the primary field from an eligible field row.

### FieldsSection
### Shared schema composer

`FieldsSection.tsx` owns all field-list state:
`FieldSchemaComposer.tsx` owns the reusable field-list interaction:

- **Drag-and-drop reorder** — native HTML5 drag API; `handleDrop` reorders `table.fields` and calls `onUpdateTable`.
- **Inline edit** — `editingFieldId` + `editState` (`FieldEditState`) track the open editor. State is owned here; `FieldEditForm` is purely presentational.
- **Edit** — opens `NewFieldDialog` with the author-facing label first and the immutable machine id/type beside each other.
- **Delete** — via `useConfirmDelete`; calls `onUpdateTable` with the field removed.
- **New field** — via `NewFieldDialog`.

For a new field, `NewFieldDialog` derives the machine ID from the label until
the author edits the ID manually. Existing IDs and types remain fixed so saved
row values keep stable keys.

`FieldsSection.tsx` computes table-specific lock, label, built-in, and delete
sets with `fieldGuards.ts`, then passes them into the composer.
`NewTableDialog.tsx` gives the same composer a local field array and submits
that array with collection identity in the single create request.

### Field classification — `fieldGuards.ts`

Tiers enforced by the guard functions:
Expand All @@ -107,22 +120,24 @@ deleteTooltip(field, table) // disabled-button tooltip text, or undefine

Built-in field **values** (row cells) are additionally read-only on the *structural* system tables (pages/components/layouts) via `isBuiltInValueLocked` (`@core/data/systemTableGuard`); `posts` built-in values stay editable. The same predicate backs the server's row-write rejection (`lockedBuiltInCellKey`).

`FIELD_TYPE_LABELS` maps every `DataFieldType` to a human-readable string and is shared by `FieldRow` and `FieldEditForm`.

### Draft/commit pattern — `fieldEditState.ts`

Field editing uses a flat draft object to keep all form inputs controlled:

```ts
fieldToEditState(field: DataField): FieldEditState // persisted → editable draft
applyEditState(field, state, labelLocked): DataField // draft → persisted
```
`FIELD_TYPE_LABELS` maps every `DataFieldType` to a human-readable string and
is shared by `FieldRow` and `FieldSchemaComposer`.

`FieldEditState` flattens all type-specific options to primitives (numeric constraints as `string`, select options as `DraftOption[]`). `applyEditState` converts them back and reconstructs the correct `DataField` discriminant via a fully-exhaustive `switch (field.type)`.
### Repeater authoring

### React Compiler — async helper extraction
`RepeaterCell.tsx` reads values through `readRepeaterCell`, initializes nested
cells through `emptyCellValue`, and writes the complete ordered value through
the ordinary row draft. Nested relation and media fields reuse their shared
pickers. Multi-media fields place `MediaPickerModal` in true multi-selection
mode: plain clicks toggle assets and the footer commits the entire selection.

`FieldsSection.tsx` and `TableSettings.tsx` extract async save handlers to **module-level functions** (`saveFieldEdit`, `saveTableField`, `savePrimaryField`). This is required because `async/await` with `try/catch` nested inside a component function forces the React Compiler to bail out of auto-memoization for that component. Extracting the async body to module scope lets the compiler memoize the component normally.
When a repeater contains exactly one single-value media field,
`MediaRepeaterGallery.tsx` replaces the generic structured-item cards with the
Media workspace presentation. It renders the shared `AssetTile` / `AssetRow`
components, uses the same persisted grid/list switcher, fills empty slots from
a multi-selection picker, and keeps replace, reorder, and remove actions on
each asset. Removing an item never deletes the underlying Media library file.
Repeaters with additional fields continue to use the generic card editor.

---

Expand Down Expand Up @@ -202,8 +217,9 @@ Both actions are opened from `DataSidebar`.
| Reimplementing title copy naming or slug collision logic when duplicating rows | Use `buildDuplicateRowCells` from `src/core/data/duplicateRow.ts` |
| Comparing field classification inline | Import from `fieldGuards.ts` |
| Adding a `kind === 'postType'` branch inside `FieldsSection` | Classification belongs in `fieldGuards.ts`; `FieldsSection` reads `isMandatoryField`, `isOptionalBuiltIn`, etc. |
| Editing a field's `type` after creation | Type is immutable; `FieldEditForm` shows it read-only with "(cannot be changed)" |
| Writing manual `useMemo`/`useCallback` in any of these components | React Compiler auto-memoizes; the only exception is the async helper extraction pattern above |
| Editing a field's type or machine id after creation | `NewFieldDialog` disables both while editing so stored row values keep stable keys |
| Allowing a repeater inside a repeater | `RepeaterItemFieldSchema` excludes `repeater`, `pageTree`, and `fieldSchema` |
| Writing manual `useMemo`/`useCallback` in any of these components | React Compiler auto-memoizes; use only the repository-level documented exceptions |
| Putting filter / sort / group logic in `DataGrid.tsx` | That logic lives in `dataGridRows.ts` (pure, side-effect free). `DataGrid.tsx` only holds interaction state and wires sub-components. |
| Treating the DataGrid as an inline cell editor | The grid is read-only. `CellEditorRenderer.tsx` belongs to the inspector (`RowDetail.tsx`), not to the grid. |
| Adding a "Table settings" shortcut to the `DataPage` toolbar | `TableSettings` is reached by deselecting a row — the inspector switches automatically. A duplicate toolbar affordance was removed; `src/__tests__/admin/data/dataPageToolbar.test.ts` prevents it from returning. |
Expand Down
2 changes: 1 addition & 1 deletion docs/features/plugin-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -744,7 +744,7 @@ const snap = await api.cms.content.getPublishedSnapshot(entryId)
const { count } = await api.cms.content.republishAll()
```

`tables.create(input)` accepts the plugin-facing field projection, then maps it to the host's canonical `DataField` schema before storage. `richText` fields default to Markdown format, `select` / `multiSelect` option `value`s become stable option IDs, and `relation.targetTableSlug` must resolve to an existing table slug.
`tables.create(input)` accepts the plugin-facing field projection, then maps it to the host's canonical `DataField` schema before storage. `richText` fields default to Markdown format, `select` / `multiSelect` option `value`s become stable option IDs, and `relation.targetTableSlug` must resolve to an existing table slug. `repeater` accepts a one-level `fields` schema made from ordinary authorable fields; nested relation slugs are resolved through the same gate, while recursive repeaters, `pageTree`, and `fieldSchema` item fields are rejected by the boundary schema.

`republishAll` fires the full publish pipeline (`publish.before` → `publish.html` → `publish.after`), so other plugins' filters and listeners participate.

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/persistence-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Catalog of every `localStorage` / `sessionStorage` key the admin app writes, and
| `instatic-clipboard-v1` | The editor clipboard (copy / cut / paste of layer subtrees) | `src/admin/pages/site/store/clipboard/clipboardStorage.ts` → `CLIPBOARD_STORAGE_KEY` |
| `instatic-class-usage` | Recently-used classes in the ClassPicker autocomplete | `src/admin/pages/site/preferences/classUsage.ts` → `CLASS_USAGE_STORAGE_KEY` |
| `instatic-data-grid-primary-widths-v1` | Per-table primary-column widths in the Data workspace grid | `src/admin/pages/data/components/DataGrid/usePrimaryColumnWidth.ts` |
| `instatic-media-page-view-mode` | Media workspace view mode (grid / list / large thumbs) | `src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx` |
| `instatic-media-page-view-mode` | Shared Media workspace and media-gallery view mode (grid / list) | `src/admin/pages/media/utils/viewMode.ts` |
| `instatic-media-explorer-view-mode` | Media Explorer panel view mode (site workspace) | `src/admin/pages/site/panels/MediaExplorerPanel/mediaExplorerUtils.ts` → `VIEW_MODE_STORAGE_KEY` |
| `instatic-module-inserter-v1` | Module inserter view mode and recent inserts | `src/admin/pages/site/module-picker/moduleInserterPrefs.ts` |
| `instatic-onboarding-dismissed` | Dashboard onboarding panel: dismissed / open per-device | `src/admin/pages/dashboard/hooks/useOnboardingState.ts` |
Expand Down
11 changes: 0 additions & 11 deletions server/handlers/cms/data/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ import {
import { publishDataRow, removeDataRowArtefact } from '../../../publish/publishRow'
import { findUserById } from '../../../repositories/users'
import { slugForTable } from '@core/data/cells'
import { lockedBuiltInCellKey } from '@core/data/systemTableGuard'
import { badRequest, jsonResponse, readValidatedBody } from '../../../http'
import { bumpPublishVersionSerialized } from '../../../publish/publishState'
import type { CmsHandlerOptions } from '../shared'
Expand Down Expand Up @@ -168,16 +167,6 @@ async function handleRowItemPatch(
const table = await getDataTable(db, currentRow.tableId)
if (!table) return rowNotFound()

// Built-in field values on structural system tables (pages/components/
// layouts) are editor-managed — reject hand-edits through the Data grid.
// The site editor writes those trees via its own endpoints, not here.
if (body.cells) {
const locked = lockedBuiltInCellKey(table, body.cells)
if (locked) {
return badRequest(`The "${locked}" field is managed by the editor and can't be edited here.`)
}
}

const rawCells = body.cells ?? currentRow.cells
// Run the `content.entry.cells` filter pipeline before persistence so
// plugins can validate / normalize / auto-fill cells — the same shared
Expand Down
7 changes: 5 additions & 2 deletions server/handlers/cms/data/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ import {
requireDataCreator,
requireDataTablesRead,
} from './access'
import { assertSystemTableUpdateAllowed, lockedBuiltInCellKey } from '@core/data/systemTableGuard'
import {
assertSystemTableUpdateAllowed,
protectedBuiltInCreateCellKey,
} from '@core/data/systemTableGuard'
import { requireStepUp } from '../../../auth/authz'

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -308,7 +311,7 @@ async function handleTableRows(

// Editor-managed built-in values can't be set through the Data grid.
if (body.cells) {
const locked = lockedBuiltInCellKey(table, body.cells)
const locked = protectedBuiltInCreateCellKey(table, body.cells)
if (locked) {
return badRequest(`The "${locked}" field is managed by the editor and can't be set here.`)
}
Expand Down
70 changes: 64 additions & 6 deletions server/plugins/host/contentFieldMapping.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,69 @@
import type { ContentTableSchema as ContentTableSchemaShape } from '@core/plugin-sdk/contentSchemas'
import type { DataField } from '@core/data/schemas'
import type { PluginRepeaterItemField } from '@core/plugin-sdk/types/content'
import type { DataField, RepeaterItemField } from '@core/data/schemas'
import { listDataTables } from '../../repositories/data'
import type { DbClient } from '../../db/client'

type PluginContentFieldForCreate = ContentTableSchemaShape['fields'][number]

function pluginFieldCommon(field: PluginContentFieldForCreate): {
function pluginFieldCommon(field: { id: string; label: string; required?: boolean }): {
id: string
label: string
required?: boolean
} {
const withRequired = field as { required?: boolean }
return {
id: field.id,
label: field.label,
...(withRequired.required !== undefined ? { required: withRequired.required } : {}),
...(field.required !== undefined ? { required: field.required } : {}),
}
}

function pluginRepeaterItemFieldToDataField(
field: PluginRepeaterItemField,
tableIdBySlug: Map<string, string>,
): RepeaterItemField {
switch (field.type) {
case 'text':
case 'longText':
case 'number':
case 'boolean':
case 'date':
case 'dateTime':
case 'url':
case 'email':
return { ...pluginFieldCommon(field), type: field.type }
case 'richText':
return { ...pluginFieldCommon(field), type: field.type, format: 'markdown' }
case 'select':
case 'multiSelect':
return {
...pluginFieldCommon(field),
type: field.type,
options: field.options.map((option) => ({
id: option.value,
value: option.value,
label: option.label,
})),
}
case 'media':
return {
...pluginFieldCommon(field),
type: field.type,
mediaKind: field.mediaKind,
allowMultiple: field.allowMultiple,
}
case 'relation': {
const targetTableId = tableIdBySlug.get(field.targetTableSlug)
if (!targetTableId) {
throw new Error(
`Relation field "${field.id}" targets unknown table "${field.targetTableSlug}"`,
)
}
return {
...pluginFieldCommon(field),
type: field.type,
targetTableId,
allowMultiple: field.allowMultiple,
}
}
}
}

Expand Down Expand Up @@ -70,6 +119,15 @@ export function pluginContentFieldsToDataFields(
})
break
}
case 'repeater':
out.push({
...pluginFieldCommon(field),
type: field.type,
fields: field.fields.map((itemField) =>
pluginRepeaterItemFieldToDataField(itemField, tableIdBySlug)),
itemLabelFieldId: field.itemLabelFieldId,
})
break
}
}

Expand Down
Loading
Loading