diff --git a/docs/tech-debt.md b/docs/tech-debt.md index d4cd737a..3e4f31e8 100644 --- a/docs/tech-debt.md +++ b/docs/tech-debt.md @@ -367,20 +367,27 @@ The same selector shape affects user-visible save side effects. `didPostSaveRequ -**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. diff --git a/includes/Rest/DocumentsController.php b/includes/Rest/DocumentsController.php index 814148a4..61684f66 100644 --- a/includes/Rest/DocumentsController.php +++ b/includes/Rest/DocumentsController.php @@ -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 ); } @@ -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. * diff --git a/src/components/CollectionDataViews.js b/src/components/CollectionDataViews.js index 68df4673..0fa57c3c 100644 --- a/src/components/CollectionDataViews.js +++ b/src/components/CollectionDataViews.js @@ -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, @@ -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'; @@ -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 @@ -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, @@ -567,7 +578,7 @@ export default function CollectionDataViews( { notifyCollectionRowsChanged( collectionId ); return updated; }, - [ collectionId, refresh, touchRecent ] + [ collectionId, refresh, saveEntityRecord, touchRecent ] ); let dataViewLayoutType = 'table'; @@ -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', @@ -974,7 +985,7 @@ export default function CollectionDataViews( { ); } }, - [ collectionId, refresh, touchRecent ] + [ collectionId, receiveEntityRecords, refresh, touchRecent ] ); const forgetDeletedRows = useCallback( @@ -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 = []; @@ -1089,6 +1096,7 @@ export default function CollectionDataViews( { forgetDeletedRows, openRowId, postType, + receiveEntityRecords, refresh, setFavorites, ] diff --git a/src/components/DataViewNewRowButton.js b/src/components/DataViewNewRowButton.js index 5cc35e8a..294605cb 100644 --- a/src/components/DataViewNewRowButton.js +++ b/src/components/DataViewNewRowButton.js @@ -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 @@ -46,6 +47,7 @@ export default function DataViewNewRowButton( { } ) { const [ isCreating, setIsCreating ] = useState( false ); const [ error, setError ] = useState( null ); + const createRowDocument = useCreateRowDocument(); const prefillableFieldIds = useMemo( () => @@ -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 ) { @@ -83,7 +79,13 @@ export default function DataViewNewRowButton( { } finally { setIsCreating( false ); } - }, [ collectionId, view, prefillableFieldIds, onCreated ] ); + }, [ + collectionId, + view, + prefillableFieldIds, + onCreated, + createRowDocument, + ] ); const button = (