Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions docs/tech-debt.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,20 +367,27 @@ The same selector shape affects user-visible save side effects. `didPostSaveRequ

<a id="td-rows-not-in-core-data"></a>

**Collection row queries bypass `core-data`.**
**Row writes use `core-data`, but row lists do not.**

**What.** Rows share the static `crtxt_document` post type with pages and collections, but collection views still fetch them through `/cortext/v1/rows`. That endpoint handles field-aware filters, sorting, calculations, relation hydration, and the fallback from paged server queries to bounded client-side queries. As a result, `useCollectionRows` also owns fetch state, race protection, and a manual `refresh()` counter. Mutations save the underlying document with `apiFetch`, then refresh open row queries. Trash and restore use row and document-trash events to refresh collection views and the sidebar. Relation chips add another read path through `useCollectionRowsByIds` so the picker can load selected labels without walking the collection.
**What.** Field saves and row creation now use `saveEntityRecord`. Duplicate, trash, and restore responses also update `core-data` with the server's canonical REST record.

**Where.** `src/hooks/useCollectionRows.js`, `src/hooks/useCollectionRowsByIds.js`, `src/hooks/rowInvalidation.js`, `src/hooks/documentTrashInvalidation.js`, and `src/hooks/useTrashedDocuments.js`, with call sites in `src/components/CollectionDataViews.js`, `src/components/RowProperties.js`, `src/components/EditableCell.js`, `src/components/SidebarTrash.js`, `src/router/EntityRoute.js`, `src/documents/actions.js`, and `src/components/relations/RelationEditor.js`.
Reads have not moved. `useCollectionRows` still fetches full rows from `/cortext/v1/rows` and manages fetch state, its `requestId` race guard, the manual `refresh()` counter, and server/client mode. Relation chips have their own read path through `useCollectionRowsByIds` and `include[]`.

**Solution.** Keep `/cortext/v1/rows` as the collection-query projection while it provides behavior the standard endpoint cannot express, but use `core-data` as the canonical store for individual `crtxt_document` records and writes. Row query results can prime that store or return document IDs alongside computed field data. `saveEntityRecord` can then update the shared record cache, while the collection-query cache only invalidates projections affected by that record. This would remove several local workarounds:
Every mutation still triggers `refresh()` and the row and trash invalidation events. `RowProperties` keeps optimistic values because its REST row can be stale after `core-data` saves a change.

- The `refresh()` handles and invalidation events exist only because rows aren't reactive.
- Half of `RowMutationContext` (also driven by [td-dataviews-inline-editing](#td-dataviews-inline-editing)) exists because cells do not write through the shared `core-data` record.
- `onCreated` still runs optimistic `lastPage = ceil((totalItems+1)/perPage)` arithmetic for unconstrained views. With reactive pagination Cortext could watch `totalPages` instead of guessing.
- Relation label lookup can use the shared document records instead of a one-off include query.
The row editor mounts inside a subregistry, which makes it look like row saves are split across two stores. They are not. `withRegistryProvider` in `@wordpress/editor` builds that subregistry with only `core/block-editor` and `core/editor` and passes the root registry as its fallback, so `core` resolves to the root. Property saves, editor autosave, and inline grid saves all reach the same entity store and share its per-record lock, so they serialize against each other. This is worth stating because the subregistry suggests the opposite. There is also a WordPress upgrade risk: if the post-type pre-persist hook starts adding sync metadata, every partial cell save will include it.

The query planner, field calculations, and hydrated relation data can remain behind the row endpoint. Document identity and mutations need one owner; product queries can keep the shape they need.
**Where.** Writes are in `src/components/rowDocumentMutations.js`, `src/components/rowDocumentCreation.js`, and `src/documents/mutations.js`. Custom reads and manual cache synchronization remain in `src/hooks/useCollectionRows.js`, `src/hooks/useCollectionRowsByIds.js`, `src/hooks/rowInvalidation.js`, `src/hooks/documentTrashInvalidation.js`, `src/hooks/useTrashedDocuments.js`, `src/components/CollectionDataViews.js`, `src/components/RowProperties.js`, `src/components/relations/RelationEditor.js`, `src/components/SidebarTrash.js`, and `src/router/EntityRoute.js`.

**Solution.** Fetch the ordered IDs with `/cortext/v1/rows?shape=ids`, load the shared `crtxt_document` records through `core-data` in stable `include` chunks, then put them back in ID order. Once the grid and relation picker use those records, `core-data` can handle record caching, race protection, and updates after a mutation. This removes:

- The `refresh()` handles and invalidation events exist only because rows aren't reactive.
- Half of `RowMutationContext` (also driven by [td-dataviews-inline-editing](#td-dataviews-inline-editing)) exists because cells can't reach a `core-data` store that isn't there.
- `onCreated` still runs optimistic `lastPage = ceil((totalItems+1)/perPage)` arithmetic for unconstrained views. With reactive pagination Cortext could watch `totalPages` instead of guessing.
- The server/client planner only decides how to query IDs; it no longer doubles as a record cache.
- Relation label lookup becomes a normal entity-record resolver instead of a one-off include query.

The record query must request `draft`, `private`, and `publish` explicitly. WordPress otherwise returns only published rows. Cross-document views such as Trash can stay behind `/cortext/v1/documents/*` endpoints.

<a id="td-modified-by-plugin-stored"></a>

Expand Down
15 changes: 12 additions & 3 deletions includes/Rest/DocumentsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,15 @@ public function duplicate( WP_REST_Request $request ): WP_REST_Response|WP_Error
return $result;
}

// Costs one internal REST request that runs the full row enrichment
// (relations, rollups, formula materialization) for the new row. That
// buys clients a canonical record they can cache instead of the
// envelope, so a null here still leaves the duplicate successful.
$post = get_post( (int) $result['id'] );
$result['post'] = $post instanceof WP_Post
? $this->prepared_post( $post )
: null;

return new WP_REST_Response( $result, 201 );
}

Expand Down Expand Up @@ -580,9 +589,9 @@ private static function content_depends_on_collection( string $content, int $col
}

/**
* Runs the standard `WP_REST_Posts_Controller` against the given document
* so the response payload matches what `useEntityRecord` already knows how
* to consume. Lets clients drop a follow-up GET after a successful restore.
* Returns the document in the REST post shape expected by `useEntityRecord`.
* Restore and duplicate responses can include it instead of requiring
* another GET.
*
* @param WP_Post $post Document post to render.
*
Expand Down
34 changes: 21 additions & 13 deletions src/components/CollectionDataViews.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import apiFetch from '@wordpress/api-fetch';
import { Notice } from '@wordpress/components';
import { store as coreStore } from '@wordpress/core-data';
import { useDispatch } from '@wordpress/data';
import { DataViews } from '@wordpress/dataviews/wp';
import {
useCallback,
Expand Down Expand Up @@ -84,6 +85,10 @@ import { toDataViewId, toRecordId } from '../hooks/fieldIds';
import useCollectionRows from '../hooks/useCollectionRows';
import { useRecents } from '../hooks/useRecents';
import { filterFavoritesByDeletedIds, useFavoriteToggle } from '../documents';
import {
duplicateDocumentRecord,
trashDocumentRecord,
} from '../documents/mutations';
import { useFavorites } from '../hooks/useFavorites';
import { elementsFromOptions } from '../hooks/optionElements';
import { notifyDocumentTrashChanged } from '../hooks/documentTrashInvalidation';
Expand All @@ -109,6 +114,7 @@ export default function CollectionDataViews( {
} ) {
const { fields, collection, isResolving, fieldsResolved } =
useCollectionFieldsContext();
const { receiveEntityRecords, saveEntityRecord } = useDispatch( coreStore );
const { touchRecent } = useRecents();
// Field IDs from the last schema sync. We use this to auto-show fields
// the user just created. `null` on first run means the saved view should
Expand Down Expand Up @@ -557,7 +563,12 @@ export default function CollectionDataViews( {
if ( ! collectionId || ! rowId ) {
return null;
}
const updated = await saveRowDocumentField( rowId, fieldId, value );
const updated = await saveRowDocumentField(
saveEntityRecord,
rowId,
fieldId,
value
);
touchRecent( {
kind: 'row',
id: updated?.id ?? rowId,
Expand All @@ -567,7 +578,7 @@ export default function CollectionDataViews( {
notifyCollectionRowsChanged( collectionId );
return updated;
},
[ collectionId, refresh, touchRecent ]
[ collectionId, refresh, saveEntityRecord, touchRecent ]
);

let dataViewLayoutType = 'table';
Expand Down Expand Up @@ -955,10 +966,10 @@ export default function CollectionDataViews( {
}
setRowActionError( null );
try {
const created = await apiFetch( {
path: `/cortext/v1/documents/${ row.id }/duplicate`,
method: 'POST',
} );
const created = await duplicateDocumentRecord(
row,
receiveEntityRecords
);
if ( created?.id ) {
touchRecent( {
kind: 'row',
Expand All @@ -974,7 +985,7 @@ export default function CollectionDataViews( {
);
}
},
[ collectionId, refresh, touchRecent ]
[ collectionId, receiveEntityRecords, refresh, touchRecent ]
);

const forgetDeletedRows = useCallback(
Expand Down Expand Up @@ -1014,11 +1025,7 @@ export default function CollectionDataViews( {
const results = await allSettledWithConcurrency(
nextRows,
BULK_DELETE_CONCURRENCY,
( row ) =>
apiFetch( {
path: `/wp/v2/crtxt_documents/${ row.id }`,
method: 'DELETE',
} )
( row ) => trashDocumentRecord( row, receiveEntityRecords )
);

const deletedIds = [];
Expand Down Expand Up @@ -1089,6 +1096,7 @@ export default function CollectionDataViews( {
forgetDeletedRows,
openRowId,
postType,
receiveEntityRecords,
refresh,
setFavorites,
]
Expand Down
24 changes: 13 additions & 11 deletions src/components/DataViewNewRowButton.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import apiFetch from '@wordpress/api-fetch';
import { Button, Notice } from '@wordpress/components';
import { useCallback, useMemo, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { plus } from '@wordpress/icons';

import { useCreateRowDocument } from './rowDocumentCreation';

// Pull a simple `is` prefill from the active filters. Multi-value operators
// are ignored for now; this path only handles one scalar value per field.
// Filters already run on GET /cortext/v1/rows, so prefill is only a convenience
Expand Down Expand Up @@ -46,6 +47,7 @@ export default function DataViewNewRowButton( {
} ) {
const [ isCreating, setIsCreating ] = useState( false );
const [ error, setError ] = useState( null );
const createRowDocument = useCreateRowDocument();

const prefillableFieldIds = useMemo(
() =>
Expand All @@ -65,15 +67,9 @@ export default function DataViewNewRowButton( {
setError( null );
const meta = prefillFromFilters( view?.filters, prefillableFieldIds );
try {
const created = await apiFetch( {
path: '/wp/v2/crtxt_documents',
method: 'POST',
data: {
status: 'private',
title: '',
cortext_trait: collectionId,
...( Object.keys( meta ).length ? { meta } : {} ),
},
const created = await createRowDocument( {
collectionId,
meta,
} );
onCreated( created );
} catch ( err ) {
Expand All @@ -83,7 +79,13 @@ export default function DataViewNewRowButton( {
} finally {
setIsCreating( false );
}
}, [ collectionId, view, prefillableFieldIds, onCreated ] );
}, [
collectionId,
view,
prefillableFieldIds,
onCreated,
createRowDocument,
] );

const button = (
<Button
Expand Down
11 changes: 2 additions & 9 deletions src/components/DocumentInspectorSidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import {
ComplementaryArea,
store as interfaceStore,
} from '@wordpress/interface';
import apiFetch from '@wordpress/api-fetch';

import CanvasOwnerInspector, {
useIsCanvasOwnerSelected,
Expand All @@ -60,6 +59,7 @@ import {
} from './page-queries';
import { DOCUMENT_POST_TYPE, FULL_PAGE_COLLECTION_QUERY } from '../collections';
import { definesTrait } from '../documents/capabilities';
import { trashDocumentRecord } from '../documents/mutations';
import { unlock } from '../lock-unlock';
import { notifyDocumentTrashChanged } from '../hooks/documentTrashInvalidation';
import { notifySidebarTreeChanged } from '../hooks/sidebarTreeInvalidation';
Expand Down Expand Up @@ -564,14 +564,7 @@ function PageActionsPanel( { postId } ) {
setError( null );
setIsTrashing( true );
try {
const deleted = await apiFetch( {
path: `/wp/v2/crtxt_documents/${ postId }`,
method: 'DELETE',
} );
const trashed = deleted?.previous ?? deleted;
if ( trashed?.id ) {
receiveEntityRecords( 'postType', POST_TYPE, [ trashed ] );
}
await trashDocumentRecord( { id: postId }, receiveEntityRecords );
invalidateResolution( 'getEntityRecords', [
'postType',
POST_TYPE,
Expand Down
29 changes: 22 additions & 7 deletions src/components/EditorBody.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
* `RowDetailView` for side peek and modal panes.
*/

import apiFetch from '@wordpress/api-fetch';
import {
BlockCanvas,
BlockList,
Expand All @@ -19,7 +18,11 @@ import {
} from '@wordpress/block-editor';
import { createBlock } from '@wordpress/blocks';
import { Button, Disabled, Notice } from '@wordpress/components';
import { useEntityProp, useEntityRecord } from '@wordpress/core-data';
import {
store as coreStore,
useEntityProp,
useEntityRecord,
} from '@wordpress/core-data';
import { useDispatch, useSelect } from '@wordpress/data';
import { store as editorStore } from '@wordpress/editor';
import { __ } from '@wordpress/i18n';
Expand All @@ -44,6 +47,7 @@ import {
import MediaPicker, { MediaUploadCheck } from './MediaPicker';
import { parseDocumentIcon } from './DocumentIcon';
import afterNextPaint from '../hooks/afterNextPaint';
import { restoreDocumentRecord } from '../documents/mutations';

const DOCUMENT_ICON_BLOCK = 'cortext/document-icon';
const DOCUMENT_COVER_BLOCK = 'cortext/document-cover';
Expand Down Expand Up @@ -1312,18 +1316,23 @@ function HeaderAwareRootAppender( { ownerBlockName, postId, record } ) {
);
}

function TrashedNotice( { postId, postType, onRestored } ) {
function TrashedNotice( {
postId,
postType,
onRestored,
receiveEntityRecords,
} ) {
const [ isRestoring, setIsRestoring ] = useState( false );
const [ error, setError ] = useState( null );

const restore = async () => {
setError( null );
setIsRestoring( true );
try {
const response = await apiFetch( {
path: `/cortext/v1/documents/${ postId }/restore`,
method: 'POST',
} );
const response = await restoreDocumentRecord(
{ id: postId },
receiveEntityRecords
);
onRestored?.( postId, postType, response );
} catch ( err ) {
setError(
Expand Down Expand Up @@ -1613,7 +1622,12 @@ export default function EditorBody( {
extraStyles,
onReady,
onRestored,
receiveEntityRecords,
} ) {
const { receiveEntityRecords: registryReceiveEntityRecords } =
useDispatch( coreStore );
const receiveRestoredRecords =
receiveEntityRecords ?? registryReceiveEntityRecords;
const baseStyles = useSelect(
( select ) => select( editorStore ).getEditorSettings().styles,
[]
Expand Down Expand Up @@ -1769,6 +1783,7 @@ export default function EditorBody( {
postId={ postId }
postType={ postType }
onRestored={ onRestored }
receiveEntityRecords={ receiveRestoredRecords }
/>
) }
{ isReadOnly ? (
Expand Down
10 changes: 10 additions & 0 deletions src/components/RowEditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// (toolbar, modal frame, navigation buttons) lives in RowDetailView and
// renders synchronously; only this inner stack suspends on first row open.
import { useDispatch } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { EditorProvider, store as editorStore } from '@wordpress/editor';
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
import { SlotFillProvider } from '@wordpress/components';
Expand Down Expand Up @@ -124,6 +125,7 @@ function DetailPaneContent( {
row,
rowId,
shouldAcquirePostLock,
receiveEntityRecords,
} ) {
const postLock = usePostLock( {
postId: row?.id ?? rowId,
Expand Down Expand Up @@ -181,6 +183,7 @@ function DetailPaneContent( {
extraStyles={ ROW_DETAIL_EXTRA_STYLES }
onReady={ handleReady }
onRestored={ onRestored }
receiveEntityRecords={ receiveEntityRecords }
/>
<PostLockModal
isOpen={ postLock.isLocked }
Expand Down Expand Up @@ -242,6 +245,12 @@ export default function RowEditor( {
rowId,
shouldAcquirePostLock = false,
} ) {
// EditorProvider's subregistry only re-registers core/block-editor and
// core/editor, so `core` resolves to the root registry from either side of
// the boundary. Reading the dispatcher here keeps that explicit: lifecycle
// responses land in the record cache RowDetailView and the grid read.
const { receiveEntityRecords } = useDispatch( coreStore );

return (
<EditorProvider
post={ post }
Expand Down Expand Up @@ -277,6 +286,7 @@ export default function RowEditor( {
row={ row }
rowId={ rowId }
shouldAcquirePostLock={ shouldAcquirePostLock }
receiveEntityRecords={ receiveEntityRecords }
/>
</EditorSurfaceProvider>
</SlotFillProvider>
Expand Down
Loading
Loading