Collections: Add inline row editing to the DataView block#31
Merged
Conversation
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.
1000993 to
22adbda
Compare
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.
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.movThere 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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
EditableCellclick-to-edit wrapper around the relevant@wordpress/componentsinputs (TextControl, NumberControl, SelectControl, DateTimePicker, CheckboxControl), plus a smallMultiselectEdit(FormTokenField) since DataViews v6 has no built-in multiselect dataform control.Wiring a
RowMutationContextfrom the block down to the cells, with asaveRowFieldcallback that POSTs{ title }for the title path and{ meta: { [fieldId]: value } }for everything else, then refreshes the row list on success.useCollectionRowsexposes a newrefresh()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.sortwill override this once sorting lands.Tech debt
This also captures the upstream Gutenberg gaps and workarounds needed, like:
Testing Instructions
Newbutton at the bottomnpm run test:e2e -- tests/e2e/specs/data-view-block.spec.jsDemo
Grabacion.de.pantalla.2026-04-29.a.las.0.33.43.mov
Grabacion.de.pantalla.2026-04-29.a.las.12.24.23.mov