Skip to content

Collections: Add inline row editing to the DataView block#31

Merged
priethor merged 33 commits into
mainfrom
priethor/add/inline-row-editing
Apr 29, 2026
Merged

Collections: Add inline row editing to the DataView block#31
priethor merged 33 commits into
mainfrom
priethor/add/inline-row-editing

Conversation

@priethor

@priethor priethor commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

What

Closes #105
Adds inline cell editing and a "New row" button to the DataView block.

Why

Inline editing keeps users in flow while filling in or correcting data, matching how databases like Notion or Airtable behave, and a footer "+ New" button gives them a familiar way to append rows without leaving the block.

How

  • Introducing an EditableCell click-to-edit wrapper around the relevant @wordpress/components inputs (TextControl, NumberControl, SelectControl, DateTimePicker, CheckboxControl), plus a small MultiselectEdit (FormTokenField) since DataViews v6 has no built-in multiselect dataform control.

  • Wiring a RowMutationContext from the block down to the cells, with a saveRowField callback that POSTs { title } for the title path and { meta: { [fieldId]: value } } for everything else, then refreshes the row list on success. useCollectionRows exposes a new refresh() handle so callers can re-fetch after a mutation.

  • Adding a tertiary, full-width "+ New" button under the DataViews table behind a thin top border. Clicking it POSTs to /wp/v2/crtxt_{slug}, prefilling meta from any single-equality filter on the active view, refreshes the rows, and flags the new row's title cell to auto-open for renaming.

  • The default row order is set to oldest-first, so newly created rows land at the bottom (and the view jumps to that page); an explicit view.sort will override this once sorting lands.

Tech debt

This also captures the upstream Gutenberg gaps and workarounds needed, like:

  • No inline cell editing in DataViews
  • No footer slot

Testing Instructions

  1. Open a post containing a DataView block bound to a Cortext collection that has at least one editable field of each type (text, number, select, multiselect, date, checkbox).
  2. Click the New button at the bottom
  3. Start adding content, hit the Tab key to move to the next cell
  4. Run the e2e spec: npm run test:e2e -- tests/e2e/specs/data-view-block.spec.js

Demo

Grabacion.de.pantalla.2026-04-29.a.las.0.33.43.mov
Grabacion.de.pantalla.2026-04-29.a.las.12.24.23.mov

@priethor priethor changed the title Expose a refresh() handle on useCollectionRows Collections: Add inline row editing to the DataView block Apr 28, 2026
priethor added 29 commits April 29, 2026 12:11
Rows are fetched outside core-data, so post-mutation invalidation has no
natural channel today. Bumping a refreshKey from a returned callback
gives callers a way to re-fire the fetch after they create or update a
row.
EditableCell is a click-to-edit wrapper around the relevant
@wordpress/components inputs (TextControl, NumberControl, SelectControl,
DateTimePicker, CheckboxControl). It reads a saveRowField callback off a
RowMutationContext so the cell stays decoupled from how the row is
persisted, and respects an autoFocusRowId so the title cell of a freshly
created row can pop straight into edit mode.

MultiselectEdit is a small FormTokenField wrapper that translates
between option labels (what the field shows) and option values (what we
store as meta). Multiselect has no built-in dataform control in
DataViews v6, hence the bespoke piece.

Styles live alongside since they only describe these components.
mapField now produces a render function per field, so DataViews shows
each cell through EditableCell instead of the default plain text
display. Editable types (text, number, email, url, select, multiselect,
date, datetime, checkbox) get the click-to-edit treatment; anything
outside that set, including formula and rollup, is rendered read-only.
A primary New button now sits in the DataViews header slot. It POSTs a
row to /wp/v2/crtxt_{slug}, prefilling meta from any single-equality
filter active on the view (operator 'is' with a scalar value, fields
known to the collection). After creation it refreshes the row list and
flags the new row's title cell to auto-open for renaming, mirroring the
sidebar's behaviour for new pages.

The block also now provides a RowMutationContext with a saveRowField
callback the cells use to persist edits. The title path POSTs { title }
while everything else POSTs { meta: { [fieldId]: value } }; both paths
refresh on success so the table reflects the change without a reload.

The existing TITLE_FIELD got a render through EditableCell so titles
themselves can be edited inline.
Two new specs:
- Click 'New' on a view filtered by a single-equality clause and verify
  a new row appears via REST with the filtered field prefilled.
- Click into a text cell, type, press Enter, and verify the new value
  shows in the table and round-trips to REST.

The block markup helper grew a viewOverrides arg so we can bake filters
into the saved view without driving the DataViews filter UI.
Notion appends new rows at the bottom of a database. WP REST defaults
to date-descending, which dropped freshly-created rows on top instead.
Pin orderby=date order=asc here until proper sort forwarding lands —
explicit view.sort can override later when the TODO above this is
addressed.
The button used to live in the DataViews header next to the View config.
That reads as a generic toolbar action; Notion puts the equivalent
affordance below the table where it visually continues the row stack.

Drop the header slot, render a tertiary, full-width '+ New' button
under DataViews behind a thin top border, and after a successful
creation jump the view to the page where the new row will land
(oldest-first sort means it's the last page) so the user lands on
their row instead of staring at page 1.
The previous commit pinned orderby=date order=asc unconditionally, which
would silently undo any view.sort the user picks once sort forwarding
lands. Make the asc-by-date default kick in only when view.sort.field
isn't set, so an explicit user sort wins by default.

Same condition gates the auto-jump-to-last-page after creation: under a
custom sort the new row's position isn't predictable, so refresh in
place rather than guessing where to send the user.
Capture what we shipped behind workarounds during the inline-row
editing work, leading with upstream Gutenberg gaps (no inline cell
editing in DataViews, no multiselect control, no footer slot) and
following with internal cleanup (rows in core-data, sort and filter
forwarding). Number every entry and reference them as 'tech-debt.md#N'
from the spots in code that work around them, so grep 'tech-debt.md#3'
shows every place affected by a given gap.
Press Tab while editing a text-like cell to commit the value and pop
the next editable cell open for editing; Shift+Tab steps backwards.
Read-only types (formula, rollup) are skipped automatically; the
walker stops at the table edges so we don't try to cross pagination
or run off the end.

The plumbing reuses the existing 'open this cell' channel: what was
autoFocusRowId for the title cell on a new row generalizes into an
editRequest of { rowId, fieldId } that any cell can be the target of.
Tab handling lives in TextLikeEditor since select/date/multiselect
have their own focus management we don't want to fight.
Tab and Shift+Tab now work consistently across editable cell types:
- Select cells intercept Tab and hop to the next cell. Value changes
  already commit via SelectControl's onChange, so there's nothing to
  flush on the way out.
- Checkbox cells get a wrapper ref so editRequest can focus the
  underlying input, plus an onKeyDown that catches Tab. The cell stays
  a direct toggle; Tab just moves on.
- Date, datetime, and multiselect still own their internal keyboard
  model (date picker subfields, FormTokenField token entry); we don't
  hijack Tab there.

While we're tidying: empty cells no longer render a literal 'Empty'
placeholder. The cell's min-height + hover background already make it
clickable, and Notion shows truly empty cells as empty space.
SelectControl renders a native <select>, which sticks out next to the
rest of the inline editors. Replace it with a Dropdown + MenuItem list
(same primitive DateEditor uses), so the picker is a styled trigger
button plus a regular @wordpress/components popover menu.

Behaviour stays the same: opens on click, picking a value commits and
closes, clicking outside cancels, Tab on the trigger hops to the next
cell. A 'Clear' menu item handles unsetting the value, replacing the
'Select…' empty option.
Tab/Shift+Tab between cells lives in the same workaround layer as the
rest of the inline-edit code — RowMutationContext, requestNext, the
editRequest channel — so it would all go away if DataViews itself
owned inline editing. Update item 1 to call out requestNext, the new
keyboard navigation, and a fresher line-count estimate. Add a
tech-debt.md#1 cross-reference next to requestNext so the link is
visible from the code too.
Empty cells were sized to their (zero-content) bounding box, leaving a
sliver that's hard to click. Pin the wrapper to width 100%, bump
min-height to 32px for a comfortable tap target, and use a vertical
flex container so single-child cells (display, checkbox, editing all
wear this class) stay centered.
CheckboxControl renders its label prop as a visible <label> next to
the input regardless of hideLabelFromVision (the prop is ignored —
checked the source). The DataViews column header already shows the
field name, so every checkbox cell was repeating it next to the box.
Pass the label as aria-label instead so screen readers still get it
without the visual duplicate.
The cell wrapper sat at 32px in display mode and the inline editors
(TextControl, NumberControl, SelectControl with __next40pxDefaultSize)
came in at 40px, so opening a cell bumped the row by 8px and shoved
neighbouring rows around. Bump min-height to 40px so display and edit
share the same height and the row stays put when the editor pops in.
Some inline editors brought their own intrinsic min-width (TextControl,
NumberControl) or sized to their label (the select / date Dropdown
trigger Buttons), so opening one resized the column. Let the cell
wrapper shrink (min-width: 0), force editor children to width 100%,
pin the Dropdown wrapper to fill the cell, and left-align the trigger
buttons so the editing state lines up with the display state.
#1 grows a paragraph on the layout coupling we landed in this round:
the cell wrapper is hardcoded to 40px to match __next40pxDefaultSize,
plus the min-width: 0 / > * width: 100% / Dropdown trigger CSS used to
keep editor controls from pushing the column. All of it would be
DataViews's job in a native inline-edit mode.

New #7 covers the CheckboxControl quirk we hit while removing the
duplicate label: hideLabelFromVision is silently ignored, the label
prop always renders visibly. We use aria-label as the workaround;
record the bug and the workaround so the next person hitting it has
context. Reference #7 from the checkbox cell so the link is visible
from the code.
NumberControl (the experimental one) renders at a different height
than TextControl: no __nextHasNoMarginBottom support, plus inline
spin buttons that nudge the input layout. Switching to a TextControl
with type='number' keeps numeric cells height-identical to the rest,
so opening Year on a Painting (or any number cell) no longer jitters
the row. We lose the spin buttons; for collection rows in a table the
keyboard arrows still work via the native number input.
Order entries by impact instead of categorical Upstream/Internal split,
so the highest-leverage debt is at the top and the trivial bits are at
the bottom. Tag each entry inline as [upstream] or [internal] for the
context that label provided.

Renumber accordingly: rows-in-core-data is now #2 (was #4), sort
forwarding is #3, filter forwarding is #4, multiselect is #5, footer
slot is #6, CheckboxControl quirk stays #7. The numbers are stable
references so update every tech-debt.md#N comment in the code to
match. Inline editing in DataViews stays #1.

Tighten the body of each entry: drop the labeled Where/Today/Cleaner/
Cost/Action sections in favour of a file pointer line and flowing
prose, since the structure was earning less than it was costing in
boilerplate.
The previous commit (0a8c624) swapped NumberControl for TextControl
under the wrong premise: I thought NumberControl rendered taller, so
opening a number cell bumped the row. Both render at 40px under
__next40pxDefaultSize. The real cause was NumberControl's default
spinControls='custom', which inserts spin buttons as a suffix inside
the input and widens the cell on focus. That's the horizontal shift
you actually saw on Year.

Restore NumberControl and pass spinControls='none' so neither the
custom buttons nor the browser-native arrows render. Keyboard up/down
still increments the value via the native number input semantics.
DataViews renders a real <table> with table-layout: auto, so each
column auto-sizes to the widest cell. The min-width: 0 / > * width: 100%
trick I added earlier kept the editor sized inside the cell, but the
cell's intrinsic min-content was still the editor (a TextControl with
40px height + intrinsic input padding = wider than display text), so
the whole column kept reflowing.

Switch to an overlay approach: always render the display shell so its
width sets the column's intrinsic size, and mount the editor as a
position: absolute overlay on top. Absolutely-positioned children
don't contribute to their parent's intrinsic size, so the table never
sees the editor's wider min-content and the column stays put.

Checkbox cells stay on a flat .cortext-cell-checkbox class since they
don't need the shell/overlay split (no display vs editor distinction).
Update tech-debt.md#1 to describe the new overlay approach instead of
the obsolete CSS workarounds.
The cell overlay (column anchoring against table-layout: auto) and the
40px row-height pin (matching __next40pxDefaultSize) are real internal
mechanisms we maintain. They exist because of the upstream gap in #1,
but they're our code and our maintenance burden, so they deserve their
own #5 internal entry rather than being a single line under #1's cost.

New #5 covers both. Renumber the existing items: multiselect 5->6,
footer 6->7, checkbox 8 (was 7). Update the in-code tech-debt.md#N
comments accordingly and add a fresh #5 reference at the overlay site
in src/index.scss so grep tech-debt.md#5 lands on the layout layer.
Drop two em dashes from the doc while we're in there.
Adds a light What/Where/Solution structure to every entry. The body
content is the same; the labels just make it easier to scan when you
land on an item: What is the problem, Where is the load-bearing code,
Solution is how this gets cleaner. Heading is bold-prefixed rather
than h3 so the entry still reads as flowing notes, not a structured
spec. Mention the new convention in the intro.
The list of items already lives in priority order at the top of the
file, so the trailing Sequencing section was just restating what the
ordering already implies. Cut it.
Two visible problems in editing state:

* The select / date trigger Buttons inherited tertiary's link style
  (blue text, hover underline), which read like a hyperlink instead
  of a cell value. Override the colour, weight, background, and box
  shadow so the toggle reads as plain text inside the cell shell.

* The multiselect editor echoed its column label (FormTokenField
  ignores hideLabelFromVision the same way CheckboxControl does, see
  tech-debt.md#8) and wore a 240px min-width that pushed it past the
  cell into the next column. Pass label='' so the duplicate disappears
  and replace the min-width with width: 100% / min-width: 0 so the
  editor sits inside the column.

Drop the now-unused 'label' prop from MultiselectEdit and from the
caller while we're there.
The block was rendering at the default content width (~640px), which
is fine for prose but cramped for a table that wants to show several
columns. Add align: ['wide', 'full'] support and default the align
attribute to 'full' so a freshly-inserted DataView fills the canvas
without the user toggling alignment from the toolbar.
Two visual issues remained from the last polish round:

* The select / date trigger sat on a white overlay background, which
  read as a separate input field rather than as cell text. Drop the
  overlay's background to transparent so the trigger flows with the
  row's natural background, matching the display state.

* The multiselect editor was still inline (FormTokenField + Save and
  Cancel laid out as a row inside the cell), which looked transactional
  and crowded the column. Convert MultiselectEdit to use a Dropdown
  trigger like the select cell: the cell shows the comma-joined
  selected labels (or 'Select…') and the FormTokenField + Save/Cancel
  live inside a popover. Click outside the popover cancels, Save
  commits.

Also adds the multiselect toggle to the trigger-button styling rule
so it picks up the same flat, cell-text appearance as select/date.
Multiselect now persists every add/remove of a token rather than
gating on a Save button. The popover stays open while you're editing;
clicking outside or the trigger again dismisses it. Switch the prop
to onSave (which calls saveRowField directly) so saving doesn't close
the cell, and drop the Save/Cancel button row inside the popover.

While we're in here, await onCommit before calling onClose on select
MenuItem clicks. The save was already firing in the background but
serializing it removes any race risk between commit and the popover
close path.
Two bugs fed into 'cells don't save on the second Demo collection':

* SeedDummyCollections::seed_collection looked up existing collections
  via get_posts({ meta_key: 'slug' }) without setting post_status. WP's
  default for get_posts is 'publish', so our 'private' seeded
  collections never matched and each 'wp cortext seed' run created a
  fresh duplicate. Two Demos, two Books, two Paintings now sit in the
  DB sharing slugs.

* CollectionEntries::register_for_collection early-returned once the
  dynamic crtxt_<slug> CPT existed, so only the first collection that
  matched a slug got its field meta registered. Subsequent collections'
  field IDs (122-130 for the older Demo, 170-178 for the newer one)
  weren't registered for REST, so saves on the unregistered side
  silently dropped meta updates.

The seeder lookup now includes draft/private/publish so re-runs are
properly idempotent. CollectionEntries always calls register_field_meta
for the current collection, even when the CPT is already registered, so
every collection sharing a slug contributes its field keys.
@priethor priethor force-pushed the priethor/add/inline-row-editing branch from 1000993 to 22adbda Compare April 29, 2026 10:15
When I stitched our two new tests onto main's spec by appending after
line 495, I dropped main's third test's closing '	} );' along with the
trailing describe close, then re-added describe's close at the end.
Net effect: the 'drops dead field references' test wasn't terminated,
so node and prettier choked at EOF.

Add the missing close back. Local prettier --check, lint, and unit
suites all clean now. Static checks should go green next CI run.
@priethor

Copy link
Copy Markdown
Collaborator Author

I've done some polishing to the fields and interactions, but probably went too far and the PR became too big:

Grabacion.de.pantalla.2026-04-29.a.las.12.24.23.mov

There is still A LOT of polishing to do. I'm going to merge this PR to prevent it from becoming "everything" and favor smaller follow-ups instead, like polishing the select/multiselect blocks UI and keyboard navigation.

* Drop the full-width default to wide. Block alignment is being
  developed in a separate workspace; reaching all the way to 'full'
  here without the accompanying CSS layer leaves the inline block
  hanging off the constrained content column. Wide is a safe middle
  ground that gives the table room without breaking out of the post
  layout.

* The inline-edit e2e was failing on strict-mode 'getByLabel("Author")'
  matching both the cell's display shell (carries aria-label so screen
  readers name it in button mode) and the TextControl input that pops
  in on edit. Switch to getByRole('textbox', { name: 'Author' }) which
  only matches the actual input and lets the test pass.
@priethor priethor marked this pull request as ready for review April 29, 2026 10:34
@priethor priethor merged commit e8187e5 into main Apr 29, 2026
6 checks passed
@priethor priethor deleted the priethor/add/inline-row-editing branch May 6, 2026 09:21
priethor added a commit that referenced this pull request May 11, 2026
* Make page chrome generic so rows can reuse it

Introduces a `cortext-document` post type support flag and
`DocumentIdentity::register_for_post_type()` helper so any CPT can opt
into the shared chrome (icon meta, locked title block, identity
controls, trash mechanics). Page and dynamic row CPTs both opt in.

Renames the page-flavored pieces to their document-flavored names:
PageIdentity, PageIconBlock, PageCoverBlock, PageTrashController,
PageIdentityControls, src/blocks/page-{icon,cover}, and the
cortext_page_icon meta key all become cortext_document_*. RevisionThrottle
gates on post_type_supports('cortext-document') instead of hard-coding
the page CPT.

Extracts the shared editor body into EditorBody and the row property
panel into RowProperties so Canvas (full-page documents) and
RowDetailView (side and modal panes) render the same editor surface
for any document, with the trash banner sitting outside the locked
canvas region so the Restore button stays interactive.

* Address pages and rows by id with one URL shape

Documents (pages and rows) now live at /<slug>-<id>; the slug is
cosmetic and the trailing id is authoritative, so renames don't break
existing links and a fresh draft with no slug stays addressable.

A new /cortext/v1/documents/<id> locator endpoint takes any id and
returns its post type, rest_base, and slug, which lets a single resolver
fetch any document without the URL having to encode the post type.
Collections keep their own collection/<slug>-<id> shape since they're
schema containers, not documents.

Also fixes the row-to-collection lookup that drives the property panel
and breadcrumb on full-page rows. The previous query used REST `?slug=`
(which filters post_name) with no status param. Collections store their
canonical slug in `meta.slug` and are created `private`, so the lookup
returned nothing for any collection with a long title or a non-publish
status. Now it reuses the workspace-wide collection list query and
matches `meta.slug` client-side, mirroring the PHP helper.

* Stop persisting row peek state in the URL

Side and modal row detail panes now live in local React state instead
of being driven by `?row=<id>` (with a paired `?rowCollection=<id>`).
The URL contract was already shaky: a page with two cortext/data-view
blocks pointing at the same collection had no way to disambiguate which
block owned the open pane, and sidebar navigation would carry stale
`?row` into the next route.

Full mode is the only row state that round-trips through the URL, and
it does so as a regular document path (/<slug>-<id>). Side and modal
panes open via click and close via dismiss; refresh closes them, which
is the same trade row detail panes have always made on collection-list
embeddings.

* Update tech-debt notes for the document chrome rename

Renames page-flavored references in entries #31, #32, #33, #34 to match
the cortext-document trait. Adds #41 covering the row-properties block
that needs to land for rows to publish cleanly through `the_content()`.

* Fix Canvas mount gating so documents load

The URL unification commit gated the Canvas mount on
`active.kind === 'document'`, but `active` only flips after Canvas
fires `DOCUMENT_DISPLAYED`, which it can't fire until it mounts.
Chicken-and-egg, so opening a page (or any document) stalled with
nothing rendered.

Mount Canvas as soon as a document is mounted; pane visibility still
flows through `WorkspacePane` and the `isActive` prop.

* Show row document icons next to titles in collection tables

Surface `cortext_document_icon` in the row table response and render
it in `TitleCell` next to the title. The icon sits in the cell's
leading gutter so the title text keeps the same x-position whether or
not a row has an icon.

* Restore grid layout for title cell so row height stays compact

The previous CSS made the title cell `display: flex` always, which
disrupted the row's natural height. Bring back the grid layout for
`--with-open-action` (matches main); the icon stays absolute-positioned
in the leading gutter, so the title still doesn't shift when an icon is
present.

* Place row icon in title cell gutter so title stays aligned

Slot the icon as the leading grid column when present, instead of
absolute-positioning it outside the cell. The previous absolute
approach combined with `position: relative` on the title cell looked
like it was interacting badly with row height in selected rows.
Accepting a small title indent on rows that have icons keeps the
layout predictable.

* Stretch title cell to fill the td so selection bg covers the row

When another column wraps and grows the row, the title cell div was
content-height, so its gray selection/hover background only painted
behind the title content. Make the title cell fill the td vertically
(`height: 100%`) so the highlight reaches both edges of the column
even on tall rows.

* Highlight title cell on its own hover, not on row hover

Previously the title cell darkened whenever any cell in the row was
hovered, so hovering Status painted the title gray too. Move the bg
trigger to a direct `:hover` on the title cell so only the actually
hovered cell darkens. The Open-button visibility-on-row-hover rule
stays untouched, so the affordance still appears across the whole row.

* Scope block-list min-height to root, not nested block layouts

Side peek and modal injected `min-height: 180px` on every
`.block-editor-block-list__layout`, which affects nested block
layouts too: a `core/list` block has its own inner block list per
item, so each list item ballooned to 180px tall. Full page never
passed these extra styles, so it didn't see the rule. Limit the
min-height to the root container so the empty-editor click target
still works without distorting nested blocks.

* Suppress block move animation while EditorBody hydrates headers

When EditorBody mounts on a row that doesn't have its locked title /
icon / cover blocks in post_content yet, EnsureHeaderBlocks inserts
them on the fly. Block-editor's `useMovingAnimation` then animates the
existing body blocks sliding into their new indices, which reads as
"the icon is being inserted right now" every time the user switches
between rows in side peek.

`useMovingAnimation` already skips the animation while `isTyping` is
true (so Enter-to-split doesn't visually shove the rest of the post
around). Wrap our header-block inserts in a startTyping/stopTyping
pair to reuse that escape hatch: the animation is decided during the
layout effect with typing on, then we release the flag on the next
frame. No follow-up edits trigger animations they shouldn't.

Once a row is saved, prepend_header_blocks lands the title block
server-side and the rehydration is a no-op.

* Move full-mode hide-properties affordance next to the properties

Adds a small chevron-up button at the top-left of the
`cortext-row-detail--canvas-properties` wrapper that's only rendered
when the properties panel is visible. Click hides the panel; the
top-bar toggle ("Show fields" / "Hide fields") still flips both ways,
so it doubles as the only way to bring the panel back.

The button lives on the Canvas-only wrapper, not inside RowProperties,
so side peek and modal panes are untouched. Affordance fades in on
wrapper hover (or button focus) to keep the panel itself uncluttered.

* Show a chevron-down to expand collapsed properties in full mode

When the user hides the properties via the top-bar toggle or the
hide-chevron, the canvas now keeps a thin strip with a chevron-down
where the panel used to live. Click brings the properties back. The
top-bar toggle still works too, but the strip is the discoverable
affordance (top bar gets lost in chrome). The strip lives on the
Canvas-only wrapper, so side peek and modal panes are unaffected.

* Float canvas-properties chevron over the cover and animate the collapse

Same chevron handles both directions now, parked at the top-left of the
canvas-properties wrapper via `position: absolute`. When the panel is
expanded it sits at the top of the panel; when it's collapsed the
wrapper has zero height, so the chevron lands over the document cover
instead. No more mid-page strip when fields are hidden.

The collapse uses a `grid-template-rows: 1fr → 0fr` transition with the
body child set to `overflow: hidden`, which is the smooth way to
animate to a content-sized natural height without hard-coding it.

* Hydrate row relations and rollups in the standard WP REST response

Peek and modal panes fetch rows via `useEntityRecord`, which goes
through `/wp/v2/<rest_base>/<id>` and returns whatever `register_post_meta`
left in place: raw stored IDs for relation fields, no value at all for
rollups (they aren't real meta). `RelationReferences` and the rollup
display then drop everything as malformed, so albums / album counts /
album genres etc. read "Empty" in the side pane even when the table
view shows them populated.

Hook `rest_prepare_<post_type>` for every row CPT and overwrite
`response.meta.field-<id>` for relation and rollup fields with the
hydrated shape `/cortext/v1/rows` already produces. Same hydrators
the table feed uses (`format_relation_value`, `compute_rollup_value`),
so both surfaces stay in sync.

Pages, collections, and field CPTs aren't touched because they don't
appear in `CollectionEntries::get_entry_post_types()`.

* Give the canvas-properties chevron a solid pill so it's legible over dark covers

Floating the chevron over the cover meant relying on the cover's
brightness for the icon to be visible. On a dark cover the icon
disappeared. Add a near-white background pill with a soft shadow so
the chevron has its own contrast surface regardless of what's behind.
Also keep it always visible when properties are collapsed, since it's
the only affordance in that state.

* Divide properties from blocks with a hairline in side peek and modal

The properties form and the document body sat flush against each other
in side and modal panes, so the visual transition from form fields to
editor canvas was unclear. Add a hairline border at the top of the
EditorBody wrapper inside the row-detail context, using the existing
`--cortext-row-detail-border` token. Full mode still has the canvas
properties chevron / animation as the divider, so it stays untouched.

* Match the properties-bottom divider to the header-bottom one

Was using `var(--cortext-row-detail-border)` for the new line under
properties; the existing line above properties (`__header`'s border-
bottom) uses `$border-width solid $gray-200`. Switch to the same
token so both edges of the properties form look identical.

* Add the same divider under the full-page properties panel

Hairline below the canvas properties body so the boundary between
fields and document body is consistent with side / modal. Border lives
on the body (not the wrapper) so it collapses with the content when
the panel is closed instead of leaving a stray line over the cover.

* Drop the title cell darker bg on open rows

Title cell was getting a `gray-100` background on `--is-open` on top of
the row's own `#f8f8f8` selected bg, so the title cell looked a notch
darker than the rest of the row. The row-level highlight is already
the selection signal; let the title cell match it. Hover and
focus-within still keep their per-cell darker bg.

* Fix data view shell layout

* Isolate row peek editor in its own subregistry

Side / modal panes mounted `<EditorProvider>` for the row without
`useSubRegistry`, so the row shared the global `core/editor` store
with whatever editor was already on screen. When a row peek opens
inside a `cortext/data-view` block on a page, the row and the parent
page fight for the same store: the autosave selector ends up reading
the wrong post's flags, and `flushNow()` reports success of the row's
REST write as a failure (because the page is clean so its
`didPostSaveRequestFail` may be set, or its `isDirty` is false). The
user sees a "failed to save" notice on writes that actually went
through.

Spinning up a subregistry per pane gives the row its own editor store,
isolated from the parent page editor (and from other simultaneously
open peeks). Saves dispatch against the right post, and the parent
page's editor state isn't touched at all. Full mode is unaffected
because Canvas already passes `useSubRegistry` through.

* Stop bouncing hydrated relation/rollup values back through meta on save

Two compounding bugs caused infinite `POST /wp/v2/<row-cpt>/<id>` 400
loops when editing any row property:

1. The new `rest_prepare_<row-cpt>` filter overwrote
   `meta.field-<id>` for relation and rollup fields with hydrated
   objects/arrays. Those meta keys are registered as `string`, so on
   save the editor round-tripped the hydrated shape back to REST and
   WP rejected the payload. The autosave then retried, hit the same
   400, retried again, etc.

2. `splitPropertyPatch` was merging the entire current meta into
   every patch before handing it to `editPost`. That made every meta
   key look edited even when the user only touched one field, which
   ensured the offending hydrated values got included in the save
   payload.

Server: keep `meta` as the canonical raw store. Move the hydrated
relation/rollup map onto a parallel `cortext_hydrated_meta` response
key so consumers that need display values have them, but the save
path still writes strings back and round-trips cleanly.

Client: `splitPropertyPatch` only emits the changed keys now; the
caller no longer passes `currentMeta`. `RowProperties` reads
relation/rollup display values from the new
`cortext_hydrated_meta` attribute and keeps using `meta` for the
text/number/select fields that are actually editable in the form.

* Revert "Stop bouncing hydrated relation/rollup values back through meta on save"

This reverts commit bb188ce.

* Stop merging current meta into row property edits

`splitPropertyPatch` was building `{ ...currentMeta, ...patch }` and
handing that to `editPost`, marking every meta key edited even when
the user only touched one field. With relation / rollup meta hydrated
in the response (registered as `string` but populated with objects /
computed values), every save round-tripped the hydrated shape back to
REST and got 400, looping the autosave.

Only emit the changed keys. `editPost` merges meta partially in
`core/editor`, so unchanged hydrated values stay out of the save
payload entirely.

* Reapply "Stop bouncing hydrated relation/rollup values back through meta on save"

This reverts commit c06c248.

* Update e2e specs for the document URL refactor

The PR dropped the `/page/` prefix from page document URLs (everything
is just `<slug>-<id>` now) and stopped persisting the row peek's
state in the URL (`?row` / `?rowCollection` are gone). The CI's
Playwright suite still expected the old shape in a handful of places:

- `admin.visitAdminPage(..., 'page=cortext&p=/page/<id>')` no longer
  loads the page because `parseIdFromUri` doesn't find an id behind a
  slash. Switched the visits to `p=/<id>`.
- `appPath.toContain('/page/')` was the old way to assert "we landed
  on a page document." Now we just check the path contains the
  document's id.
- The data-view-block spec was verifying row peek navigation by
  polling `?row` and exercising browser Back / Forward. Side and
  modal panes are local React state now, so neither path holds. The
  Row above / Row below button assertions already cover the same
  thing semantically.

* Surface row system fields on the standard WP REST response

Full-page row documents pull their `row` prop from `useResolveDocument`,
which hits `/wp/v2/<rest_base>/<id>`. That response only exposed the
raw post (`author`, `date_gmt`, …), not the hydrated `created_by` /
`modified_by` display names the field getters expect, so the system
columns rendered as "Empty" in full mode even though side peek and
modal (which load via `/cortext/v1/rows`) showed them correctly.

Extend the existing `rest_prepare_<row-cpt>` filter to also surface
`created_at`, `created_by`, `modified_at`, `modified_by` at the
top level of the row response, mirroring `format_row` on the table
feed. Resolver's `_fields` query is widened to pull them through.

* Update e2e specs for the document trait rename and dual-iframe context

Two CI Playwright failures after the latest merge:

- The trashed-document banner says "This document is in trash." since
  we generalised page identity to documents. The test was still
  asserting the old "This page is in trash." copy.
- The data-view-block test scoped its frame via
  `.cortext-canvas__visual iframe[name="editor-canvas"]`. When the
  row peek opens, RowDetailView mounts its own EditorBody (and
  another such iframe), so the locator now resolves to two elements
  and trips Playwright's strict mode. Pin the canvas to the parent
  page's `role="region" name="Content"` region so the row peek's
  iframe doesn't get picked up.

* Update row-detail e2e to mirror the chrome-title / iframe-edit split

The row detail chrome now renders the post title as a read-only
`<h2>` and the editable title lives inside the row's EditorBody
iframe (locked `core/post-title` block). The spec was still expecting
a `<textbox name="Title">` inside the dialog.

- Switch the `detailTitle` locator to the chrome heading and use
  `toHaveText` instead of `toHaveValue`.
- Drop the title-edit-via-chrome step. The Author / Year edits below
  still cover the row-property save round-trip, so the test's intent
  is preserved.
- Adjust downstream breadcrumb / REST assertions to expect the
  original title ("The Left Hand of Darkness") since the title is
  no longer being changed in the spec.

* Drop DOM-identity check on Tags label across row switches

The spec stamped a `data-e2e-stable-label` attribute on row 1's Tags
property label, then asserted it survived on row 2 to prove React
reused the same DOM node across the switch. With per-pane editor
subregistries the second row mounts a fresh subtree, so the marker
is gone. Replace the survival check with a plain visibility check
on the Tags label using its normal selector. The functional intent
("row 2 still renders the Tags field") is preserved without
asserting an internal DOM identity that's not user-visible.

* Preserve unsaved edits across cross-type navigation

Switching between a page and a row document re-mounted the editor
immediately, dropping any in-flight edits on the previous document. The
type-mismatch fallback in `renderedPost` short-circuited before the
`pendingPost`/`flushNow` path that handles same-type swaps, so the flush
only ran for page-to-page navigation.

Keep `displayedPost` mounted regardless of type until CanvasEditor has
flushed, route the rendered type downstream so child chrome stays
coherent during the transition, and tighten the pending-post effect to
compare both id and type.

* Update row-detail e2e for hide-fields toggle and breadcrumb collection nav

`.cortext-row-detail__fields-indicator` and `.cortext-row-detail__content-editor`
no longer exist after the iframe refactor; the properties panel stays
mounted with `data-visible="false"` when collapsed.

Clicking the collection in a row's breadcrumb now navigates to the
collection's management surface, which renders DataViews directly
(no editor iframe), so scope the post-navigation assertion to the page.
@priethor priethor added this to the 0.1.0 milestone May 28, 2026
@priethor priethor added type: enhancement Improvement to existing behavior. area: collections Fields, rows, views, DataViews, relations, rollups, formulas, and row properties. labels May 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: collections Fields, rows, views, DataViews, relations, rollups, formulas, and row properties. type: enhancement Improvement to existing behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Let users create rows and edit cells inline

1 participant