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 = (
select( editorStore ).getEditorSettings().styles,
[]
@@ -1769,6 +1783,7 @@ export default function EditorBody( {
postId={ postId }
postType={ postType }
onRestored={ onRestored }
+ receiveEntityRecords={ receiveRestoredRecords }
/>
) }
{ isReadOnly ? (
diff --git a/src/components/RowEditor.js b/src/components/RowEditor.js
index 73bf8f3b..6b632810 100644
--- a/src/components/RowEditor.js
+++ b/src/components/RowEditor.js
@@ -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';
@@ -124,6 +125,7 @@ function DetailPaneContent( {
row,
rowId,
shouldAcquirePostLock,
+ receiveEntityRecords,
} ) {
const postLock = usePostLock( {
postId: row?.id ?? rowId,
@@ -181,6 +183,7 @@ function DetailPaneContent( {
extraStyles={ ROW_DETAIL_EXTRA_STYLES }
onReady={ handleReady }
onRestored={ onRestored }
+ receiveEntityRecords={ receiveEntityRecords }
/>
diff --git a/src/components/RowProperties.js b/src/components/RowProperties.js
index 21fa952f..f8eebb05 100644
--- a/src/components/RowProperties.js
+++ b/src/components/RowProperties.js
@@ -11,7 +11,8 @@
*/
import { Button, CheckboxControl } from '@wordpress/components';
-import { useSelect } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+import { useDispatch, useSelect } from '@wordpress/data';
import { store as editorStore } from '@wordpress/editor';
import {
Fragment,
@@ -1035,6 +1036,7 @@ export default function RowProperties( {
updateFieldFormat,
refreshRows,
} = useContext( RowMutationContext );
+ const { saveEntityRecord } = useDispatch( coreStore );
const [ localOptionOverrides, setLocalOptionOverrides ] = useState( {} );
const [ localFormatOverrides, setLocalFormatOverrides ] = useState( {} );
const [ activeLayoutFieldId, setActiveLayoutFieldId ] = useState( null );
@@ -1075,12 +1077,9 @@ export default function RowProperties( {
},
[ updateFieldFormat ]
);
- // Field edits save as minimal REST patches (see `rowDocumentMutations`)
- // while the panel still reads from `editorStore`, so this hand-rolled
- // optimistic layer bridges the two: `local*` holds the in-flight edit and
- // `committed*` holds a saved value until the live `row` catches up. Rows in
- // core-data (tech-debt.md#td-rows-not-in-core-data) would let
- // `saveEntityRecord` do this and delete the block.
+ // Row writes use core-data, but this panel still reads from `editorStore`
+ // and the REST row. Keep in-flight (`local*`) and saved (`committed*`) values
+ // here until row reads move too.
const [ committedMeta, setCommittedMeta ] = useState( {} );
const [ committedHydratedMeta, setCommittedHydratedMeta ] = useState( {} );
const [ committedTitle, setCommittedTitle ] = useState( undefined );
@@ -1192,6 +1191,7 @@ export default function RowProperties( {
try {
const updated = await saveRowDocumentField(
+ saveEntityRecord,
entry.rowId,
fieldId,
entry.value
@@ -1237,6 +1237,7 @@ export default function RowProperties( {
clearSaveTimer,
collectionId,
refreshRows,
+ saveEntityRecord,
]
);
@@ -1289,6 +1290,7 @@ export default function RowProperties( {
for ( const [ fieldId, entry ] of entries ) {
try {
const updated = await saveRowDocumentField(
+ saveEntityRecord,
entry.rowId,
fieldId,
entry.value
@@ -1329,6 +1331,7 @@ export default function RowProperties( {
clearLocalFieldValue,
collectionId,
refreshRows,
+ saveEntityRecord,
]
);
diff --git a/src/components/relations/RelationEditor.js b/src/components/relations/RelationEditor.js
index 59902f34..da98126b 100644
--- a/src/components/relations/RelationEditor.js
+++ b/src/components/relations/RelationEditor.js
@@ -1,5 +1,4 @@
import { __, sprintf } from '@wordpress/i18n';
-import apiFetch from '@wordpress/api-fetch';
import { Button, Dropdown, Spinner } from '@wordpress/components';
import {
useCallback,
@@ -20,6 +19,7 @@ import useCollectionRows from '../../hooks/useCollectionRows';
import useCollectionRowsByIds from '../../hooks/useCollectionRowsByIds';
import useDebouncedValue from '../../hooks/useDebouncedValue';
import { useRecents } from '../../hooks/useRecents';
+import { useCreateRowDocument } from '../rowDocumentCreation';
import { relationIds, relationTitle } from './relationUtils';
const RELATION_PICKER_PER_PAGE = 25;
@@ -52,6 +52,7 @@ export default function RelationEditor( {
const [ isCreating, setIsCreating ] = useState( false );
const [ createError, setCreateError ] = useState( '' );
const searchRef = useRef( null );
+ const createRowDocument = useCreateRowDocument();
const { touchRecent } = useRecents();
const selectedIds = useMemo( () => relationIds( value ), [ value ] );
const currentRefs = useMemo(
@@ -208,14 +209,9 @@ export default function RelationEditor( {
setIsCreating( true );
setCreateError( '' );
try {
- const created = await apiFetch( {
- path: '/wp/v2/crtxt_documents',
- method: 'POST',
- data: {
- title: createTitle,
- status: 'private',
- cortext_trait: targetCollectionId,
- },
+ const created = await createRowDocument( {
+ title: createTitle,
+ collectionId: targetCollectionId,
} );
const createdId = Number( created?.id );
if ( ! createdId ) {
diff --git a/src/components/rowDocumentCreation.js b/src/components/rowDocumentCreation.js
new file mode 100644
index 00000000..a5f06946
--- /dev/null
+++ b/src/components/rowDocumentCreation.js
@@ -0,0 +1,44 @@
+import { useDispatch } from '@wordpress/data';
+import { useCallback } from '@wordpress/element';
+
+import { DOCUMENT_POST_TYPE } from '../collections';
+
+/**
+ * Creates a row directly through core-data.
+ *
+ * @param {Function} saveEntityRecord Core-data save dispatcher.
+ * @param {Object} input Row values.
+ * @param {number} input.collectionId Collection/trait ID.
+ * @param {string} [input.title] Initial row title.
+ * @param {Object} [input.meta] Initial field values.
+ * @return {Promise} Created row record.
+ */
+export function createRowDocument(
+ saveEntityRecord,
+ { collectionId, title = '', meta }
+) {
+ const payload = {
+ status: 'private',
+ title,
+ cortext_trait: collectionId,
+ ...( meta && Object.keys( meta ).length ? { meta } : {} ),
+ };
+
+ return saveEntityRecord( 'postType', DOCUMENT_POST_TYPE, payload, {
+ throwOnError: true,
+ } );
+}
+
+/**
+ * Returns a row creator bound to core-data.
+ *
+ * @return {Function} Row creation callback.
+ */
+export function useCreateRowDocument() {
+ const { saveEntityRecord } = useDispatch( 'core' );
+
+ return useCallback(
+ ( input ) => createRowDocument( saveEntityRecord, input ),
+ [ saveEntityRecord ]
+ );
+}
diff --git a/src/components/rowDocumentMutations.js b/src/components/rowDocumentMutations.js
index 13aef2e1..cda6f226 100644
--- a/src/components/rowDocumentMutations.js
+++ b/src/components/rowDocumentMutations.js
@@ -1,5 +1,4 @@
-import apiFetch from '@wordpress/api-fetch';
-
+import { DOCUMENT_POST_TYPE } from '../collections';
import { TITLE_FIELD_ID } from './dataViewColumns';
export function rowDocumentFieldPayload( fieldId, value ) {
@@ -10,10 +9,19 @@ export function rowDocumentFieldPayload( fieldId, value ) {
return { meta: { [ fieldId ]: value } };
}
-export function saveRowDocumentField( rowId, fieldId, value ) {
- return apiFetch( {
- path: `/wp/v2/crtxt_documents/${ rowId }`,
- method: 'POST',
- data: rowDocumentFieldPayload( fieldId, value ),
- } );
+export function saveRowDocumentField(
+ saveEntityRecord,
+ rowId,
+ fieldId,
+ value
+) {
+ return saveEntityRecord(
+ 'postType',
+ DOCUMENT_POST_TYPE,
+ {
+ id: rowId,
+ ...rowDocumentFieldPayload( fieldId, value ),
+ },
+ { throwOnError: true }
+ );
}
diff --git a/src/documents/actions.js b/src/documents/actions.js
index 0c918842..ad0e746e 100644
--- a/src/documents/actions.js
+++ b/src/documents/actions.js
@@ -10,6 +10,11 @@ import { notifyCollectionRowsChanged } from '../hooks/rowInvalidation';
import { notifySidebarTreeChanged } from '../hooks/sidebarTreeInvalidation';
import { cascadeFavorites } from './favorites';
import { afterDocumentTrash, applyInvalidationPack } from './invalidation';
+import {
+ duplicateDocumentRecord,
+ restoreDocumentRecord,
+ trashDocumentRecord,
+} from './mutations';
function collectCascadeIds( record, cascade ) {
const ids = new Set( [ Number( record.id ) ] );
@@ -30,7 +35,8 @@ export async function createDocument( input, ctx ) {
const created = await ctx.saveEntityRecord(
'postType',
DOCUMENT_POST_TYPE,
- payload
+ payload,
+ { throwOnError: true }
);
if ( created?.id ) {
applyInvalidationPack( ctx.invalidateResolution, afterDocumentTrash );
@@ -83,10 +89,10 @@ export async function renameDocument( record, title, ctx ) {
}
export async function duplicateDocument( record, ctx ) {
- const created = await apiFetch( {
- path: `/cortext/v1/documents/${ record.id }/duplicate`,
- method: 'POST',
- } );
+ const created = await duplicateDocumentRecord(
+ record,
+ ctx.receiveEntityRecords
+ );
applyInvalidationPack( ctx.invalidateResolution, afterDocumentTrash );
notifySidebarTreeChanged( {
parentId: Number( created?.parent ?? record.parent ?? 0 ),
@@ -124,14 +130,10 @@ export async function duplicateDocument( record, ctx ) {
// core-data does not drop the open record before the editor finishes its
// block selection writes.
export async function trashDocument( record, ctx ) {
- const deleted = await apiFetch( {
- path: `/wp/v2/crtxt_documents/${ record.id }`,
- method: 'DELETE',
- } );
- const trashed = deleted?.previous ?? deleted;
- if ( trashed?.id ) {
- ctx.receiveEntityRecords( 'postType', DOCUMENT_POST_TYPE, [ trashed ] );
- }
+ const deleted = await trashDocumentRecord(
+ record,
+ ctx.receiveEntityRecords
+ );
applyInvalidationPack( ctx.invalidateResolution, afterDocumentTrash );
notifySidebarTreeChanged();
notifyDocumentTrashChanged();
@@ -148,14 +150,15 @@ export async function trashDocument( record, ctx ) {
}
export async function restoreDocument( record, ctx ) {
- await apiFetch( {
- path: `/cortext/v1/documents/${ record.id }/restore`,
- method: 'POST',
- } );
+ const response = await restoreDocumentRecord(
+ record,
+ ctx.receiveEntityRecords
+ );
applyInvalidationPack( ctx.invalidateResolution, afterDocumentTrash );
notifySidebarTreeChanged();
notifyDocumentTrashChanged();
notifyCollectionRowsChanged();
+ return response;
}
export async function permanentlyDeleteDocument( record, ctx ) {
diff --git a/src/documents/mutations.js b/src/documents/mutations.js
new file mode 100644
index 00000000..433800d0
--- /dev/null
+++ b/src/documents/mutations.js
@@ -0,0 +1,77 @@
+import apiFetch from '@wordpress/api-fetch';
+
+import { DOCUMENT_POST_TYPE } from '../collections';
+
+/**
+ * Adds a canonical REST record to the shared core-data cache. Mutation
+ * envelopes are partial and must stay out of the cache.
+ *
+ * @param {Object|null} record Canonical document record.
+ * @param {Function} receiveEntityRecords core-data dispatcher.
+ * @return {Object|null} Cached record, or null when it was not cached.
+ */
+export function receiveCanonicalDocumentRecord( record, receiveEntityRecords ) {
+ if ( ! record?.id || ! receiveEntityRecords ) {
+ return null;
+ }
+ receiveEntityRecords(
+ 'postType',
+ DOCUMENT_POST_TYPE,
+ [ record ],
+ undefined,
+ true
+ );
+ return record;
+}
+
+/**
+ * Duplicates a document and caches the canonical record from the response.
+ *
+ * @param {Object} record Document to duplicate.
+ * @param {Function} receiveEntityRecords core-data dispatcher.
+ * @return {Promise} Duplicate response.
+ */
+export async function duplicateDocumentRecord( record, receiveEntityRecords ) {
+ const response = await apiFetch( {
+ path: `/cortext/v1/documents/${ record.id }/duplicate`,
+ method: 'POST',
+ } );
+ receiveCanonicalDocumentRecord( response?.post, receiveEntityRecords );
+ return response;
+}
+
+/**
+ * Soft-deletes a document without evicting it from core-data. WordPress returns
+ * the trashed record directly, or under `previous` for a forced delete.
+ *
+ * @param {Object} record Document to trash.
+ * @param {Function} receiveEntityRecords core-data dispatcher.
+ * @return {Promise} Core REST delete response.
+ */
+export async function trashDocumentRecord( record, receiveEntityRecords ) {
+ const response = await apiFetch( {
+ path: `/wp/v2/crtxt_documents/${ record.id }`,
+ method: 'DELETE',
+ } );
+ receiveCanonicalDocumentRecord(
+ response?.previous ?? response,
+ receiveEntityRecords
+ );
+ return response;
+}
+
+/**
+ * Restores a document and caches the canonical record from the response.
+ *
+ * @param {Object} record Document to restore.
+ * @param {Function} receiveEntityRecords core-data dispatcher.
+ * @return {Promise} Restore response.
+ */
+export async function restoreDocumentRecord( record, receiveEntityRecords ) {
+ const response = await apiFetch( {
+ path: `/cortext/v1/documents/${ record.id }/restore`,
+ method: 'POST',
+ } );
+ receiveCanonicalDocumentRecord( response?.post, receiveEntityRecords );
+ return response;
+}
diff --git a/src/router/EntityRoute.js b/src/router/EntityRoute.js
index bed7a209..025ea769 100644
--- a/src/router/EntityRoute.js
+++ b/src/router/EntityRoute.js
@@ -390,17 +390,13 @@ export default function EntityRoute( { history } ) {
);
const isRow = Boolean( editorRowContext );
- const { invalidateResolution, receiveEntityRecords } =
- useDispatch( 'core' );
+ const { invalidateResolution } = useDispatch( 'core' );
// Restore still has two cache paths: pages use core-data for the tree, rows
// use collection-scoped queries. Both refresh the Trash list; rows also
// notify open collections because relations and rollups can change elsewhere.
const onRestoreDocument = useCallback(
- ( postId, postType, response ) => {
- if ( response?.post && postType ) {
- receiveEntityRecords( 'postType', postType, [ response.post ] );
- }
+ ( postId, _postType, response ) => {
// Every document shares one post type, so a restore always re-enters
// the workspace tree and leaves the Trash list.
invalidateResolution( 'getEntityRecords', [
@@ -425,7 +421,7 @@ export default function EntityRoute( { history } ) {
notifyCollectionRowsChanged();
}
},
- [ invalidateResolution, receiveEntityRecords, isRow ]
+ [ invalidateResolution, isRow ]
);
const editorRecentTarget =
diff --git a/tests/e2e/specs/data-view-block.spec.js b/tests/e2e/specs/data-view-block.spec.js
index 225b70f3..b01c408c 100644
--- a/tests/e2e/specs/data-view-block.spec.js
+++ b/tests/e2e/specs/data-view-block.spec.js
@@ -2563,6 +2563,151 @@ test.describe( 'Collection view block', () => {
}
} );
+ test( 'duplicates a row and keeps its field values after reload', async ( {
+ admin,
+ page,
+ requestUtils,
+ } ) => {
+ const fixture = {};
+ const duplicateTitle = 'Copy of The Left Hand of Darkness';
+
+ try {
+ Object.assign(
+ fixture,
+ await createCollectionFixture( requestUtils )
+ );
+
+ fixture.page = await requestUtils.rest( {
+ method: 'POST',
+ path: '/wp/v2/crtxt_documents',
+ data: {
+ title: 'Row duplicate test page',
+ status: 'private',
+ content: createDataViewBlockMarkup( fixture.collection.id ),
+ },
+ } );
+
+ await admin.visitAdminPage(
+ 'admin.php',
+ `page=cortext&p=/${ fixture.page.id }`
+ );
+
+ await page.waitForFunction(
+ ( postId ) =>
+ window.wp?.data
+ ?.select( 'core/editor' )
+ ?.getCurrentPostId?.() === postId,
+ fixture.page.id,
+ { timeout: 15_000 }
+ );
+
+ const canvas = page.frameLocator( '[name="editor-canvas"]' );
+ const table = canvas.locator( '.dataviews-view-table' );
+ const originalRow = table
+ .locator( 'tbody > tr' )
+ .filter( { hasText: 'The Left Hand of Darkness' } );
+
+ await expect( originalRow ).toBeVisible();
+ await originalRow.hover();
+ await originalRow
+ .getByRole( 'button', { name: 'Actions' } )
+ .click( { force: true } );
+ const duplicateResponsePromise = page.waitForResponse(
+ ( response ) =>
+ response.request().method() === 'POST' &&
+ new URL( response.url() ).pathname.endsWith(
+ `/wp-json/cortext/v1/documents/${ fixture.entry.id }/duplicate`
+ )
+ );
+ await canvas.getByRole( 'menuitem', { name: 'Duplicate' } ).click();
+ const duplicateResponse = await duplicateResponsePromise;
+ expect( duplicateResponse.ok() ).toBe( true );
+ const duplicateEnvelope = await duplicateResponse.json();
+ fixture.duplicateId = Number( duplicateEnvelope.id );
+ expect( fixture.duplicateId ).toBeGreaterThan( 0 );
+
+ const duplicateRow = table
+ .locator( 'tbody > tr' )
+ .filter( { hasText: duplicateTitle } );
+ await expect( duplicateRow ).toBeVisible();
+ await expect( duplicateRow ).toContainText( 'Ursula K. Le Guin' );
+
+ const rows = await requestUtils.rest( {
+ path: '/wp/v2/crtxt_documents',
+ params: {
+ context: 'edit',
+ status: 'draft,private,publish',
+ per_page: 100,
+ cortext_trait: fixture.collection.id,
+ },
+ } );
+ const duplicate = rows.find(
+ ( row ) => Number( row.id ) === fixture.duplicateId
+ );
+ expect( duplicate ).toBeTruthy();
+ expect( duplicate.title.raw ).toBe( duplicateTitle );
+ expect( duplicate.meta[ `field-${ fixture.field.id }` ] ).toBe(
+ 'Ursula K. Le Guin'
+ );
+
+ const cachedDuplicate = await page.evaluate(
+ ( { duplicateId, fieldKey } ) => {
+ const record = window.wp.data
+ .select( 'core' )
+ .getEntityRecord(
+ 'postType',
+ 'crtxt_document',
+ duplicateId
+ );
+ if ( ! record ) {
+ return null;
+ }
+ return {
+ id: record.id,
+ title: record.title?.raw,
+ fieldValue: record.meta?.[ fieldKey ],
+ };
+ },
+ {
+ duplicateId: fixture.duplicateId,
+ fieldKey: `field-${ fixture.field.id }`,
+ }
+ );
+ expect( cachedDuplicate ).toEqual( {
+ id: fixture.duplicateId,
+ title: duplicateTitle,
+ fieldValue: 'Ursula K. Le Guin',
+ } );
+
+ await page.reload();
+ await expect( duplicateRow ).toBeVisible();
+ await expect( duplicateRow ).toContainText( 'Ursula K. Le Guin' );
+ } finally {
+ await deleteIfCreated(
+ requestUtils,
+ fixture.duplicateId &&
+ `/wp/v2/crtxt_documents/${ fixture.duplicateId }`
+ );
+ await deleteIfCreated(
+ requestUtils,
+ fixture.entry && `/wp/v2/crtxt_documents/${ fixture.entry.id }`
+ );
+ await deleteIfCreated(
+ requestUtils,
+ fixture.page && `/wp/v2/crtxt_documents/${ fixture.page.id }`
+ );
+ await deleteIfCreated(
+ requestUtils,
+ fixture.field && `/wp/v2/crtxt_fields/${ fixture.field.id }`
+ );
+ await deleteIfCreated(
+ requestUtils,
+ fixture.collection &&
+ `/wp/v2/crtxt_documents/${ fixture.collection.id }`
+ );
+ }
+ } );
+
test( 'creates a collection from the placeholder and can switch collections', async ( {
admin,
page,
@@ -3245,6 +3390,259 @@ test.describe( 'Collection view block', () => {
}
} );
+ test( 'queues inline saves while row details are open and shows save errors', async ( {
+ admin,
+ page,
+ requestUtils,
+ } ) => {
+ const fixture = {};
+ const firstStatus = 'First queued value';
+ const successfulStatus = 'Ready for review';
+ const failedStatus = 'This must not persist';
+ const failureMessage = 'Forced row save failure.';
+ let delayedSaveHandler;
+ let failedSaveHandler;
+ let releaseFirstSave = () => {};
+ let rowSavePattern;
+
+ try {
+ Object.assign(
+ fixture,
+ await createCalculationFixture( requestUtils )
+ );
+ const statusKey = `field-${ fixture.fields.status.id }`;
+
+ fixture.page = await requestUtils.rest( {
+ method: 'POST',
+ path: '/wp/v2/crtxt_documents',
+ data: {
+ title: 'Concurrent inline row saves',
+ status: 'private',
+ content: createDataViewBlockMarkup( fixture.collection.id, {
+ fields: [ 'title', statusKey ],
+ } ),
+ },
+ } );
+
+ await admin.visitAdminPage(
+ 'admin.php',
+ `page=cortext&p=/${ fixture.page.id }`
+ );
+
+ await page.waitForFunction(
+ ( postId ) =>
+ window.wp?.data
+ ?.select( 'core/editor' )
+ ?.getCurrentPostId?.() === postId,
+ fixture.page.id,
+ { timeout: 15_000 }
+ );
+
+ const canvas = page
+ .getByRole( 'region', { name: 'Content' } )
+ .frameLocator( 'iframe[name="editor-canvas"]' );
+ const table = canvas.locator( '.dataviews-view-table' );
+ const alphaRow = table
+ .locator( 'tbody > tr' )
+ .filter( { hasText: 'Alpha Book' } );
+
+ await expect( alphaRow ).toBeVisible();
+ await alphaRow.hover();
+ await alphaRow.locator( '.cortext-title-cell__open' ).click();
+
+ const detail = page.getByRole( 'dialog', { name: 'Detail' } );
+ await expect( detail ).toBeVisible();
+ await expect(
+ activeRowDetailCanvas( detail )
+ .locator( '[data-type="core/post-title"]' )
+ .first()
+ ).toHaveText( 'Alpha Book' );
+
+ rowSavePattern = new RegExp(
+ `/wp-json/wp/v2/crtxt_documents/${ fixture.rows[ 0 ].id }(?:\\?|$)`
+ );
+ const isExpectedMetaSave = ( request, fieldKey, expectedValue ) => {
+ if (
+ request.method() !== 'POST' ||
+ ! rowSavePattern.test( request.url() )
+ ) {
+ return false;
+ }
+ try {
+ return (
+ request.postDataJSON()?.meta?.[ fieldKey ] ===
+ expectedValue
+ );
+ } catch {
+ return false;
+ }
+ };
+
+ const firstSaveGate = new Promise( ( resolve ) => {
+ releaseFirstSave = resolve;
+ } );
+ delayedSaveHandler = async ( route ) => {
+ if (
+ isExpectedMetaSave(
+ route.request(),
+ statusKey,
+ firstStatus
+ )
+ ) {
+ await firstSaveGate;
+ }
+ await route.continue();
+ };
+ await page.route( rowSavePattern, delayedSaveHandler );
+
+ const firstSaveRequest = page.waitForRequest( ( request ) =>
+ isExpectedMetaSave( request, statusKey, firstStatus )
+ );
+ const statusCell = alphaRow.getByText( 'Alpha', {
+ exact: true,
+ } );
+ // The open side peek intercepts pointer events over the canvas. Click
+ // the hidden grid control directly so this test can focus on save
+ // ordering.
+ await statusCell.evaluate( ( cell ) => cell.click() );
+ const statusInput = alphaRow.getByRole( 'textbox', {
+ name: 'Status',
+ exact: true,
+ } );
+ await statusInput.fill( firstStatus, { force: true } );
+ await statusInput.press( 'Enter' );
+ await firstSaveRequest;
+ await expect( statusInput ).toHaveValue( firstStatus );
+
+ await statusInput.fill( successfulStatus, { force: true } );
+ const secondSaveRequest = page.waitForRequest( ( request ) =>
+ isExpectedMetaSave( request, statusKey, successfulStatus )
+ );
+ await statusInput.press( 'Enter' );
+ await expect( statusInput ).toHaveValue( successfulStatus );
+
+ const secondSaveStartedWhileFirstWasPending = await Promise.race( [
+ secondSaveRequest.then( () => true ),
+ page.waitForTimeout( 250 ).then( () => false ),
+ ] );
+ expect( secondSaveStartedWhileFirstWasPending ).toBe( false );
+
+ releaseFirstSave();
+ await secondSaveRequest;
+
+ await expect
+ .poll( async () => {
+ const row = await requestUtils.rest( {
+ path: `/wp/v2/crtxt_documents/${ fixture.rows[ 0 ].id }`,
+ params: { context: 'edit' },
+ } );
+ return row.meta[ statusKey ];
+ } )
+ .toBe( successfulStatus );
+ await expect( detail ).toBeVisible();
+
+ await page.unroute( rowSavePattern, delayedSaveHandler );
+ delayedSaveHandler = null;
+ failedSaveHandler = async ( route ) => {
+ if (
+ isExpectedMetaSave(
+ route.request(),
+ statusKey,
+ failedStatus
+ )
+ ) {
+ await route.fulfill( {
+ status: 500,
+ contentType: 'application/json',
+ body: JSON.stringify( {
+ code: 'cortext_test_save_failure',
+ message: failureMessage,
+ data: { status: 500 },
+ } ),
+ } );
+ return;
+ }
+ await route.continue();
+ };
+ await page.route( rowSavePattern, failedSaveHandler );
+
+ await expect(
+ alphaRow.getByText( successfulStatus, { exact: true } )
+ ).toBeVisible();
+ await alphaRow
+ .getByText( successfulStatus, { exact: true } )
+ .evaluate( ( cell ) => cell.click() );
+ const failingInput = alphaRow.getByRole( 'textbox', {
+ name: 'Status',
+ exact: true,
+ } );
+ await failingInput.fill( failedStatus, { force: true } );
+ const failedSaveRequest = page.waitForRequest( ( request ) =>
+ isExpectedMetaSave( request, statusKey, failedStatus )
+ );
+ await failingInput.press( 'Enter' );
+ await failedSaveRequest;
+
+ await expect(
+ canvas.getByText( failureMessage, { exact: true } )
+ ).toBeVisible();
+ await expect( failingInput ).toHaveValue( failedStatus );
+
+ const persistedAfterFailure = await requestUtils.rest( {
+ path: `/wp/v2/crtxt_documents/${ fixture.rows[ 0 ].id }`,
+ params: { context: 'edit' },
+ } );
+ expect( persistedAfterFailure.meta[ statusKey ] ).toBe(
+ successfulStatus
+ );
+
+ await page.unroute( rowSavePattern, failedSaveHandler );
+ failedSaveHandler = null;
+ await page.reload();
+
+ const reloadedCanvas = page
+ .getByRole( 'region', { name: 'Content' } )
+ .frameLocator( 'iframe[name="editor-canvas"]' );
+ const reloadedRow = reloadedCanvas
+ .locator( '.dataviews-view-table tbody > tr' )
+ .filter( { hasText: 'Alpha Book' } );
+ await expect( reloadedRow ).toContainText( successfulStatus );
+ } finally {
+ releaseFirstSave();
+ if ( rowSavePattern && delayedSaveHandler ) {
+ await page
+ .unroute( rowSavePattern, delayedSaveHandler )
+ .catch( () => {} );
+ }
+ if ( rowSavePattern && failedSaveHandler ) {
+ await page
+ .unroute( rowSavePattern, failedSaveHandler )
+ .catch( () => {} );
+ }
+ for ( const row of fixture.rows ?? [] ) {
+ await deleteIfCreated(
+ requestUtils,
+ `/wp/v2/crtxt_documents/${ row.id }`
+ );
+ }
+ await deleteIfCreated(
+ requestUtils,
+ fixture.page && `/wp/v2/crtxt_documents/${ fixture.page.id }`
+ );
+ for ( const field of Object.values( fixture.fields ?? {} ) ) {
+ await deleteIfCreated(
+ requestUtils,
+ `/wp/v2/crtxt_fields/${ field.id }`
+ );
+ }
+ await deleteIfCreated(
+ requestUtils,
+ fixture.collection &&
+ `/wp/v2/crtxt_documents/${ fixture.collection.id }`
+ );
+ }
+ } );
+
test( 'row detail toolbar stays separate from the parent DataView toolbar', async ( {
admin,
page,
diff --git a/tests/js/components/DataViewNewRowButton.test.js b/tests/js/components/DataViewNewRowButton.test.js
new file mode 100644
index 00000000..97a32539
--- /dev/null
+++ b/tests/js/components/DataViewNewRowButton.test.js
@@ -0,0 +1,92 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+const mockCreateRowDocument = jest.fn();
+jest.mock( '../../../src/components/rowDocumentCreation', () => ( {
+ useCreateRowDocument: () => mockCreateRowDocument,
+} ) );
+
+import DataViewNewRowButton from '../../../src/components/DataViewNewRowButton';
+
+function renderButton( overrides = {} ) {
+ return render(
+
+ );
+}
+
+beforeEach( () => {
+ mockCreateRowDocument.mockReset();
+} );
+
+describe( 'DataViewNewRowButton', () => {
+ it( 'passes eligible filter values when creating a row', async () => {
+ const created = { id: 44, title: { raw: '' } };
+ const onCreated = jest.fn();
+ mockCreateRowDocument.mockResolvedValue( created );
+
+ renderButton( {
+ view: {
+ filters: [
+ { field: 'priority', operator: 'is', value: 'high' },
+ { field: 'readonly', operator: 'is', value: 'ignored' },
+ { field: 'rollup', operator: 'is', value: 10 },
+ { field: 'priority', operator: 'isAny', value: 'ignored' },
+ { field: 'title', operator: 'is', value: 'ignored' },
+ ],
+ },
+ fields: [
+ { id: 'priority' },
+ { id: 'readonly', editable: false },
+ { id: 'rollup', cortextType: 'rollup' },
+ ],
+ onCreated,
+ } );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'New' } ) );
+
+ await waitFor( () =>
+ expect( mockCreateRowDocument ).toHaveBeenCalledWith( {
+ collectionId: 9,
+ meta: { priority: 'high' },
+ } )
+ );
+ await waitFor( () =>
+ expect( onCreated ).toHaveBeenCalledWith( created )
+ );
+ } );
+
+ it( 'omits meta when no active filter can prefill the row', async () => {
+ mockCreateRowDocument.mockResolvedValue( { id: 45 } );
+
+ renderButton();
+ fireEvent.click( screen.getByRole( 'button', { name: 'New' } ) );
+
+ await waitFor( () =>
+ expect( mockCreateRowDocument ).toHaveBeenCalledWith( {
+ collectionId: 9,
+ meta: {},
+ } )
+ );
+ } );
+
+ it( 'shows the save error and does not report the row as created', async () => {
+ const onCreated = jest.fn();
+ mockCreateRowDocument.mockRejectedValue(
+ new Error( 'The row could not be saved.' )
+ );
+
+ renderButton( { onCreated } );
+ fireEvent.click( screen.getByRole( 'button', { name: 'New' } ) );
+
+ expect(
+ ( await screen.findAllByText( 'The row could not be saved.' ) )
+ .length
+ ).toBeGreaterThan( 0 );
+ expect( onCreated ).not.toHaveBeenCalled();
+ } );
+} );
diff --git a/tests/js/components/RowEditor.test.js b/tests/js/components/RowEditor.test.js
index 4bdaf4bb..81b302e6 100644
--- a/tests/js/components/RowEditor.test.js
+++ b/tests/js/components/RowEditor.test.js
@@ -4,12 +4,21 @@ import RowEditor from '../../../src/components/RowEditor';
import EditorBody from '../../../src/components/EditorBody';
import usePostLock from '../../../src/hooks/usePostLock';
+const mockReceiveEntityRecords = jest.fn();
+
jest.mock( '@wordpress/components', () => ( {
SlotFillProvider: ( { children } ) => <>{ children }>,
} ) );
jest.mock( '@wordpress/data', () => ( {
- useDispatch: () => ( { resetPost: jest.fn() } ),
+ useDispatch: () => ( {
+ receiveEntityRecords: mockReceiveEntityRecords,
+ resetPost: jest.fn(),
+ } ),
+} ) );
+
+jest.mock( '@wordpress/core-data', () => ( {
+ store: { name: 'core' },
} ) );
jest.mock( '@wordpress/editor', () => ( {
@@ -110,10 +119,19 @@ function renderRowEditor( overrides = {} ) {
describe( 'RowEditor', () => {
beforeEach( () => {
EditorBody.mockClear();
+ mockReceiveEntityRecords.mockClear();
usePostLock.mockReturnValue( unlockedPostLock );
window.cortextEditorSettings = {};
} );
+ it( 'uses the parent core-data receiver inside the editor subregistry', () => {
+ renderRowEditor();
+
+ expect( EditorBody.mock.calls[ 0 ][ 0 ].receiveEntityRecords ).toBe(
+ mockReceiveEntityRecords
+ );
+ } );
+
it( 'marks the pane ready after the editor body has painted', () => {
const onPaneReady = jest.fn();
diff --git a/tests/js/components/RowProperties.test.js b/tests/js/components/RowProperties.test.js
index 02c1556a..a30fad2e 100644
--- a/tests/js/components/RowProperties.test.js
+++ b/tests/js/components/RowProperties.test.js
@@ -11,9 +11,9 @@ let mockDndProps;
let mockSortableContextProps;
const mockEditPost = jest.fn();
+const mockSaveEntityRecord = jest.fn();
const mockRelationEditorProps = [];
-jest.mock( '@wordpress/api-fetch', () => jest.fn() );
jest.mock( '@wordpress/components', () => {
const { createElement, forwardRef } = require( '@wordpress/element' );
@@ -68,6 +68,10 @@ jest.mock( '@wordpress/data', () => ( {
useSelect: jest.fn(),
} ) );
+jest.mock( '@wordpress/core-data', () => ( {
+ store: { name: 'core' },
+} ) );
+
jest.mock( '@wordpress/editor', () => ( {
store: 'editor-store',
} ) );
@@ -182,7 +186,6 @@ jest.mock( '../../../src/components/relations/RelationEditor', () => ( {
},
} ) );
-import apiFetch from '@wordpress/api-fetch';
import { useDispatch, useSelect } from '@wordpress/data';
import { closestCenter, pointerWithin, useDroppable } from '@dnd-kit/core';
import { useSortable } from '@dnd-kit/sortable';
@@ -196,7 +199,7 @@ describe( 'RowProperties', () => {
beforeEach( () => {
mockDndProps = null;
mockSortableContextProps = null;
- apiFetch.mockReset();
+ mockSaveEntityRecord.mockReset();
closestCenter.mockReset();
pointerWithin.mockReset();
useDroppable.mockReturnValue( {
@@ -213,7 +216,10 @@ describe( 'RowProperties', () => {
} );
mockEditPost.mockReset();
mockRelationEditorProps.length = 0;
- useDispatch.mockReturnValue( { editPost: mockEditPost } );
+ useDispatch.mockReturnValue( {
+ editPost: mockEditPost,
+ saveEntityRecord: mockSaveEntityRecord,
+ } );
useSelect.mockReturnValue( {
title: 'Current title',
meta: { 'field-7': 'Open' },
@@ -351,9 +357,9 @@ describe( 'RowProperties', () => {
expect( handle ).not.toHaveFocus();
} );
- it( 'keeps text-like properties single-line while saving a minimal row patch', async () => {
+ it( 'keeps text-like properties on one line and saves only the changed field', async () => {
jest.useFakeTimers();
- apiFetch.mockResolvedValue( {
+ mockSaveEntityRecord.mockResolvedValue( {
id: 99,
title: { raw: 'Current title', rendered: 'Current title' },
meta: { 'field-7': 'https://example.com bad' },
@@ -388,7 +394,7 @@ describe( 'RowProperties', () => {
} );
expect( input ).toHaveValue( 'https://example.com bad' );
- expect( apiFetch ).not.toHaveBeenCalled();
+ expect( mockSaveEntityRecord ).not.toHaveBeenCalled();
await act( async () => {
jest.advanceTimersByTime( 500 );
@@ -396,13 +402,15 @@ describe( 'RowProperties', () => {
} );
await waitFor( () =>
- expect( apiFetch ).toHaveBeenCalledWith( {
- path: '/wp/v2/crtxt_documents/99',
- method: 'POST',
- data: {
+ expect( mockSaveEntityRecord ).toHaveBeenCalledWith(
+ 'postType',
+ 'crtxt_document',
+ {
+ id: 99,
meta: { 'field-7': 'https://example.com bad' },
},
- } )
+ { throwOnError: true }
+ )
);
expect( mockEditPost ).not.toHaveBeenCalled();
} finally {
@@ -443,9 +451,9 @@ describe( 'RowProperties', () => {
);
} );
- it( 'keeps a saved property visible until the row fallback catches up', async () => {
+ it( 'keeps a saved value visible while the row fallback catches up', async () => {
jest.useFakeTimers();
- apiFetch.mockResolvedValue( {
+ mockSaveEntityRecord.mockResolvedValue( {
id: 99,
meta: { 'field-7': 'Doing' },
} );
@@ -519,7 +527,7 @@ describe( 'RowProperties', () => {
it( 'keeps field save responses from overwriting other edited fields', async () => {
jest.useFakeTimers();
const resolvers = [];
- apiFetch.mockImplementation(
+ mockSaveEntityRecord.mockImplementation(
() =>
new Promise( ( resolve ) => {
resolvers.push( resolve );
@@ -569,16 +577,20 @@ describe( 'RowProperties', () => {
await Promise.resolve();
} );
- expect( apiFetch ).toHaveBeenNthCalledWith( 1, {
- path: '/wp/v2/crtxt_documents/99',
- method: 'POST',
- data: { meta: { 'field-7': 'Doing' } },
- } );
- expect( apiFetch ).toHaveBeenNthCalledWith( 2, {
- path: '/wp/v2/crtxt_documents/99',
- method: 'POST',
- data: { meta: { 'field-8': 'Tagged' } },
- } );
+ expect( mockSaveEntityRecord ).toHaveBeenNthCalledWith(
+ 1,
+ 'postType',
+ 'crtxt_document',
+ { id: 99, meta: { 'field-7': 'Doing' } },
+ { throwOnError: true }
+ );
+ expect( mockSaveEntityRecord ).toHaveBeenNthCalledWith(
+ 2,
+ 'postType',
+ 'crtxt_document',
+ { id: 99, meta: { 'field-8': 'Tagged' } },
+ { throwOnError: true }
+ );
await act( async () => {
resolvers[ 1 ]( {
@@ -820,14 +832,15 @@ describe( 'RowProperties', () => {
).not.toBeInTheDocument();
} );
- it( 'saves relation edits through the row endpoint and updates the displayed chip', async () => {
+ it( 'saves relation edits through core-data and updates the chip from the hydrated response', async () => {
const refreshRows = jest.fn();
const onRowsChanged = jest.fn();
window.addEventListener( COLLECTION_ROWS_CHANGED_EVENT, onRowsChanged );
- apiFetch.mockResolvedValue( {
+ mockSaveEntityRecord.mockResolvedValue( {
id: 99,
title: { raw: 'Source row', rendered: 'Source row' },
- meta: {
+ meta: { 'field-7': [ 456 ] },
+ cortext_hydrated_meta: {
'field-7': [
{
id: 456,
@@ -883,13 +896,15 @@ describe( 'RowProperties', () => {
);
await waitFor( () =>
- expect( apiFetch ).toHaveBeenCalledWith( {
- path: '/wp/v2/crtxt_documents/99',
- method: 'POST',
- data: {
+ expect( mockSaveEntityRecord ).toHaveBeenCalledWith(
+ 'postType',
+ 'crtxt_document',
+ {
+ id: 99,
meta: { 'field-7': [ 456 ] },
},
- } )
+ { throwOnError: true }
+ )
);
await screen.findByRole( 'button', { name: 'Grace Hopper' } );
expect( refreshRows ).toHaveBeenCalled();
diff --git a/tests/js/components/SidebarTrash.test.js b/tests/js/components/SidebarTrash.test.js
index d9f15a1d..cccc469c 100644
--- a/tests/js/components/SidebarTrash.test.js
+++ b/tests/js/components/SidebarTrash.test.js
@@ -158,11 +158,13 @@ import {
POST_TYPE,
TRASHED_PAGES_QUERY,
} from '../../../src/components/page-queries';
+import { DOCUMENT_POST_TYPE } from '../../../src/collections';
import { DOCUMENT_TRASH_CHANGED_EVENT } from '../../../src/hooks/documentTrashInvalidation';
const dispatchMocks = {
deleteEntityRecord: jest.fn(),
invalidateResolution: jest.fn(),
+ receiveEntityRecords: jest.fn(),
};
const navigateMock = jest.fn();
@@ -175,6 +177,7 @@ beforeEach( () => {
useNavigate.mockReset();
dispatchMocks.deleteEntityRecord.mockReset();
dispatchMocks.invalidateResolution.mockReset();
+ dispatchMocks.receiveEntityRecords.mockReset();
navigateMock.mockReset();
useDispatch.mockReturnValue( dispatchMocks );
useNavigate.mockReturnValue( navigateMock );
@@ -505,9 +508,13 @@ describe( 'SidebarTrash', () => {
it( 'restores a row through the document endpoint', async () => {
const refresh = jest.fn();
+ const restoredPost = makeRow( { id: 17, status: 'private' } );
setTrashRecords( { records: [ makeRow( { id: 17 } ) ] } );
trashState.refresh = refresh;
- apiFetch.mockResolvedValue( { restored: [ 17 ] } );
+ apiFetch.mockResolvedValue( {
+ restored: [ 17 ],
+ post: restoredPost,
+ } );
renderSidebarTrash();
@@ -519,6 +526,13 @@ describe( 'SidebarTrash', () => {
method: 'POST',
} );
} );
+ expect( dispatchMocks.receiveEntityRecords ).toHaveBeenCalledWith(
+ 'postType',
+ DOCUMENT_POST_TYPE,
+ [ restoredPost ],
+ undefined,
+ true
+ );
expect( refresh ).toHaveBeenCalled();
} );
diff --git a/tests/js/components/relations/RelationEditor.test.js b/tests/js/components/relations/RelationEditor.test.js
index 15ddc85d..1436cc8b 100644
--- a/tests/js/components/relations/RelationEditor.test.js
+++ b/tests/js/components/relations/RelationEditor.test.js
@@ -6,7 +6,10 @@ import {
waitFor,
} from '@testing-library/react';
-jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+const mockCreateRowDocument = jest.fn();
+jest.mock( '../../../../src/components/rowDocumentCreation', () => ( {
+ useCreateRowDocument: () => mockCreateRowDocument,
+} ) );
jest.mock( '../../../../src/hooks/useCollectionRows', () => ( {
__esModule: true,
default: jest.fn(),
@@ -28,7 +31,6 @@ jest.mock( '../../../../src/hooks/useRecents', () => ( {
useRecents: () => ( { touchRecent: mockTouchRecent } ),
} ) );
-import apiFetch from '@wordpress/api-fetch';
import RelationEditor from '../../../../src/components/relations/RelationEditor';
import useCollectionRows from '../../../../src/hooks/useCollectionRows';
import useCollectionRowsByIds from '../../../../src/hooks/useCollectionRowsByIds';
@@ -52,7 +54,7 @@ async function flushPopoverEffects() {
}
beforeEach( () => {
- apiFetch.mockReset();
+ mockCreateRowDocument.mockReset();
mockTouchRecent.mockReset();
useDebouncedValue.mockImplementation( ( value ) => value );
mockRowsResponse();
@@ -336,7 +338,10 @@ describe( 'RelationEditor', () => {
collection: { title: { raw: 'People' } },
refresh: refreshTargetRows,
} );
- apiFetch.mockResolvedValue( { id: 44, title: { raw: 'New Ada' } } );
+ mockCreateRowDocument.mockResolvedValue( {
+ id: 44,
+ title: { raw: 'New Ada' },
+ } );
const onSave = jest.fn().mockResolvedValue( true );
render(
@@ -360,14 +365,9 @@ describe( 'RelationEditor', () => {
);
await waitFor( () =>
- expect( apiFetch ).toHaveBeenCalledWith( {
- path: '/wp/v2/crtxt_documents',
- method: 'POST',
- data: {
- title: 'New Ada',
- status: 'private',
- cortext_trait: 9,
- },
+ expect( mockCreateRowDocument ).toHaveBeenCalledWith( {
+ title: 'New Ada',
+ collectionId: 9,
} )
);
await waitFor( () => expect( onSave ).toHaveBeenCalledWith( [ 44 ] ) );
@@ -378,4 +378,40 @@ describe( 'RelationEditor', () => {
expect( refreshTargetRows ).toHaveBeenCalled();
await flushPopoverEffects();
} );
+
+ it( 'keeps the picker open and shows the error when row creation fails', async () => {
+ const refreshTargetRows = jest.fn();
+ mockRowsResponse( { refresh: refreshTargetRows } );
+ mockCreateRowDocument.mockRejectedValue(
+ new Error( 'The row could not be saved.' )
+ );
+ const onSave = jest.fn();
+
+ render(
+
+ );
+
+ fireEvent.change( screen.getByLabelText( 'Search' ), {
+ target: { value: 'New Ada' },
+ } );
+ fireEvent.click(
+ screen.getByRole( 'button', {
+ name: 'Create "New Ada"',
+ } )
+ );
+
+ expect(
+ await screen.findByText( 'The row could not be saved.' )
+ ).toBeInTheDocument();
+ expect( onSave ).not.toHaveBeenCalled();
+ expect( mockTouchRecent ).not.toHaveBeenCalled();
+ expect( refreshTargetRows ).not.toHaveBeenCalled();
+ await flushPopoverEffects();
+ } );
} );
diff --git a/tests/js/components/rowDocumentCreation.test.js b/tests/js/components/rowDocumentCreation.test.js
new file mode 100644
index 00000000..83f34fb2
--- /dev/null
+++ b/tests/js/components/rowDocumentCreation.test.js
@@ -0,0 +1,98 @@
+import { act, renderHook } from '@testing-library/react';
+
+jest.mock( '@wordpress/data', () => ( {
+ __esModule: true,
+ useDispatch: jest.fn(),
+} ) );
+
+import { useDispatch } from '@wordpress/data';
+import {
+ createRowDocument,
+ useCreateRowDocument,
+} from '../../../src/components/rowDocumentCreation';
+
+describe( 'createRowDocument', () => {
+ it( 'sends the row creation payload with throwOnError enabled', async () => {
+ const saveEntityRecord = jest
+ .fn()
+ .mockResolvedValue( { id: 44, title: { raw: 'New Ada' } } );
+
+ const created = await createRowDocument( saveEntityRecord, {
+ collectionId: 9,
+ title: 'New Ada',
+ meta: { priority: 'high' },
+ } );
+
+ expect( saveEntityRecord ).toHaveBeenCalledWith(
+ 'postType',
+ 'crtxt_document',
+ {
+ status: 'private',
+ title: 'New Ada',
+ cortext_trait: 9,
+ meta: { priority: 'high' },
+ },
+ { throwOnError: true }
+ );
+ expect( created ).toEqual( {
+ id: 44,
+ title: { raw: 'New Ada' },
+ } );
+ } );
+
+ it( 'defaults the title and omits empty meta', async () => {
+ const saveEntityRecord = jest.fn().mockResolvedValue( { id: 45 } );
+
+ await createRowDocument( saveEntityRecord, {
+ collectionId: 9,
+ meta: {},
+ } );
+
+ expect( saveEntityRecord ).toHaveBeenCalledWith(
+ 'postType',
+ 'crtxt_document',
+ {
+ status: 'private',
+ title: '',
+ cortext_trait: 9,
+ },
+ { throwOnError: true }
+ );
+ } );
+
+ it( 'propagates core-data failures', async () => {
+ const error = new Error( 'Save failed.' );
+ const saveEntityRecord = jest.fn().mockRejectedValue( error );
+
+ await expect(
+ createRowDocument( saveEntityRecord, { collectionId: 9 } )
+ ).rejects.toBe( error );
+ } );
+} );
+
+describe( 'useCreateRowDocument', () => {
+ it( 'uses the core-data dispatcher', async () => {
+ const saveEntityRecord = jest.fn().mockResolvedValue( { id: 46 } );
+ useDispatch.mockReturnValue( { saveEntityRecord } );
+ const { result } = renderHook( () => useCreateRowDocument() );
+
+ await act( async () => {
+ await result.current( {
+ collectionId: 9,
+ title: 'Grace',
+ } );
+ } );
+
+ expect( useDispatch ).toHaveBeenCalledWith( 'core' );
+ expect( saveEntityRecord ).toHaveBeenCalledWith(
+ 'postType',
+ 'crtxt_document',
+ {
+ status: 'private',
+ title: 'Grace',
+ cortext_trait: 9,
+ },
+ { throwOnError: true }
+ );
+ } );
+} );
diff --git a/tests/js/components/rowDocumentMutations.test.js b/tests/js/components/rowDocumentMutations.test.js
index 9b61429a..2ce001c9 100644
--- a/tests/js/components/rowDocumentMutations.test.js
+++ b/tests/js/components/rowDocumentMutations.test.js
@@ -1,32 +1,52 @@
-import apiFetch from '@wordpress/api-fetch';
-
import {
rowDocumentFieldPayload,
saveRowDocumentField,
} from '../../../src/components/rowDocumentMutations';
-
-jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+import { DOCUMENT_POST_TYPE } from '../../../src/collections';
describe( 'rowDocumentMutations', () => {
- beforeEach( () => {
- apiFetch.mockReset();
- } );
-
it( 'saves title as a top-level document attribute', async () => {
- apiFetch.mockResolvedValue( { id: 9 } );
+ const saveEntityRecord = jest.fn().mockResolvedValue( { id: 9 } );
- await saveRowDocumentField( 9, 'title', 'New title' );
+ const saved = await saveRowDocumentField(
+ saveEntityRecord,
+ 9,
+ 'title',
+ 'New title'
+ );
- expect( apiFetch ).toHaveBeenCalledWith( {
- path: '/wp/v2/crtxt_documents/9',
- method: 'POST',
- data: { title: 'New title' },
- } );
+ expect( saveEntityRecord ).toHaveBeenCalledWith(
+ 'postType',
+ DOCUMENT_POST_TYPE,
+ { id: 9, title: 'New title' },
+ { throwOnError: true }
+ );
+ expect( saved ).toEqual( { id: 9 } );
} );
- it( 'saves collection fields as meta patches', () => {
+ it( 'saves only the changed collection field in meta', async () => {
+ const saveEntityRecord = jest.fn().mockResolvedValue( { id: 9 } );
+
expect( rowDocumentFieldPayload( 'field-7', 'Open' ) ).toEqual( {
meta: { 'field-7': 'Open' },
} );
+
+ await saveRowDocumentField( saveEntityRecord, 9, 'field-7', 'Open' );
+
+ expect( saveEntityRecord ).toHaveBeenCalledWith(
+ 'postType',
+ DOCUMENT_POST_TYPE,
+ { id: 9, meta: { 'field-7': 'Open' } },
+ { throwOnError: true }
+ );
+ } );
+
+ it( 'rejects with the original error when saving a field fails', async () => {
+ const error = new Error( 'Save failed' );
+ const saveEntityRecord = jest.fn().mockRejectedValue( error );
+
+ await expect(
+ saveRowDocumentField( saveEntityRecord, 9, 'field-7', 'Blocked' )
+ ).rejects.toBe( error );
} );
} );
diff --git a/tests/js/documents/actions.test.js b/tests/js/documents/actions.test.js
index 75ce4d4a..01e8dee4 100644
--- a/tests/js/documents/actions.test.js
+++ b/tests/js/documents/actions.test.js
@@ -44,7 +44,8 @@ describe( 'createDocument', () => {
expect( ctx.saveEntityRecord ).toHaveBeenCalledWith(
'postType',
DOCUMENT_POST_TYPE,
- { status: 'draft' }
+ { status: 'draft' },
+ { throwOnError: true }
);
expect( result ).toEqual( { id: 42, slug: 'untitled' } );
} );
@@ -61,7 +62,8 @@ describe( 'createDocument', () => {
expect( ctx.saveEntityRecord ).toHaveBeenCalledWith(
'postType',
DOCUMENT_POST_TYPE,
- { title: 'Untitled', status: 'private', parent: 3 }
+ { title: 'Untitled', status: 'private', parent: 3 },
+ { throwOnError: true }
);
} );
@@ -92,6 +94,16 @@ describe( 'createDocument', () => {
expect( result ).toBeNull();
expect( ctx.invalidateResolution ).not.toHaveBeenCalled();
} );
+
+ it( 'rejects without invalidating caches when saving fails', async () => {
+ const error = new Error( 'save failed' );
+ const ctx = makeCtx();
+ ctx.saveEntityRecord.mockRejectedValue( error );
+
+ await expect( createDocument( {}, ctx ) ).rejects.toBe( error );
+
+ expect( ctx.invalidateResolution ).not.toHaveBeenCalled();
+ } );
} );
describe( 'useCreateDocument', () => {
@@ -120,7 +132,8 @@ describe( 'useCreateDocument', () => {
expect( saveEntityRecord ).toHaveBeenCalledWith(
'postType',
DOCUMENT_POST_TYPE,
- { status: 'draft', title: 'About' }
+ { status: 'draft', title: 'About' },
+ { throwOnError: true }
);
expect( invalidateResolution ).toHaveBeenCalledTimes(
afterDocumentTrash.length
@@ -145,7 +158,8 @@ describe( 'useCreateDocument', () => {
expect( saveEntityRecord ).toHaveBeenCalledWith(
'postType',
DOCUMENT_POST_TYPE,
- { status: 'draft' }
+ { status: 'draft' },
+ { throwOnError: true }
);
} );
} );
@@ -177,7 +191,8 @@ describe( 'useCreateCollectionDocument', () => {
title: 'Tasks',
parent: 3,
cortext_collection: true,
- }
+ },
+ { throwOnError: true }
);
} );
@@ -198,7 +213,8 @@ describe( 'useCreateCollectionDocument', () => {
expect( saveEntityRecord ).toHaveBeenCalledWith(
'postType',
DOCUMENT_POST_TYPE,
- { status: 'draft', cortext_collection: true }
+ { status: 'draft', cortext_collection: true },
+ { throwOnError: true }
);
} );
} );
diff --git a/tests/js/documents/mutations.test.js b/tests/js/documents/mutations.test.js
new file mode 100644
index 00000000..db4bba89
--- /dev/null
+++ b/tests/js/documents/mutations.test.js
@@ -0,0 +1,170 @@
+jest.mock( '@wordpress/api-fetch', () => ( {
+ __esModule: true,
+ default: jest.fn(),
+} ) );
+
+import apiFetch from '@wordpress/api-fetch';
+
+import { DOCUMENT_POST_TYPE } from '../../../src/collections';
+import {
+ duplicateDocumentRecord,
+ receiveCanonicalDocumentRecord,
+ restoreDocumentRecord,
+ trashDocumentRecord,
+} from '../../../src/documents/mutations';
+
+describe( 'document mutation cache updates', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'caches a canonical record and invalidates matching queries', () => {
+ const receiveEntityRecords = jest.fn();
+ const record = { id: 17, title: { raw: 'Canonical' } };
+
+ expect(
+ receiveCanonicalDocumentRecord( record, receiveEntityRecords )
+ ).toBe( record );
+ expect( receiveEntityRecords ).toHaveBeenCalledWith(
+ 'postType',
+ DOCUMENT_POST_TYPE,
+ [ record ],
+ undefined,
+ true
+ );
+ } );
+
+ it( 'ignores missing canonical records', () => {
+ const receiveEntityRecords = jest.fn();
+
+ expect(
+ receiveCanonicalDocumentRecord( null, receiveEntityRecords )
+ ).toBeNull();
+ expect( receiveEntityRecords ).not.toHaveBeenCalled();
+ } );
+
+ it( 'caches only response.post from the duplicate endpoint', async () => {
+ const receiveEntityRecords = jest.fn();
+ const post = { id: 23, title: { raw: 'Copy' }, status: 'private' };
+ const response = {
+ id: 23,
+ title: 'Copy',
+ parent: 0,
+ post,
+ };
+ apiFetch.mockResolvedValue( response );
+
+ await expect(
+ duplicateDocumentRecord( { id: 12 }, receiveEntityRecords )
+ ).resolves.toBe( response );
+
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ path: '/cortext/v1/documents/12/duplicate',
+ method: 'POST',
+ } );
+ expect( receiveEntityRecords ).toHaveBeenCalledWith(
+ 'postType',
+ DOCUMENT_POST_TYPE,
+ [ post ],
+ undefined,
+ true
+ );
+ expect( receiveEntityRecords ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'does not cache a duplicate when response.post is null', async () => {
+ const receiveEntityRecords = jest.fn();
+ const response = { id: 23, title: 'Copy', post: null };
+ apiFetch.mockResolvedValue( response );
+
+ await expect(
+ duplicateDocumentRecord( { id: 12 }, receiveEntityRecords )
+ ).resolves.toBe( response );
+
+ expect( receiveEntityRecords ).not.toHaveBeenCalled();
+ } );
+
+ it( 'caches the canonical top-level record after moving a document to trash', async () => {
+ const receiveEntityRecords = jest.fn();
+ const response = {
+ id: 31,
+ status: 'trash',
+ title: { raw: 'Trashed document' },
+ cascade_deleted: [ 32 ],
+ };
+ apiFetch.mockResolvedValue( response );
+
+ await expect(
+ trashDocumentRecord( { id: 31 }, receiveEntityRecords )
+ ).resolves.toBe( response );
+
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ path: '/wp/v2/crtxt_documents/31',
+ method: 'DELETE',
+ } );
+ expect( receiveEntityRecords ).toHaveBeenCalledWith(
+ 'postType',
+ DOCUMENT_POST_TYPE,
+ [ response ],
+ undefined,
+ true
+ );
+ } );
+
+ it( 'caches response.previous from a wrapped delete response', async () => {
+ const receiveEntityRecords = jest.fn();
+ const previous = { id: 31, status: 'private' };
+ const response = { deleted: true, previous };
+ apiFetch.mockResolvedValue( response );
+
+ await expect(
+ trashDocumentRecord( { id: 31 }, receiveEntityRecords )
+ ).resolves.toBe( response );
+
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ path: '/wp/v2/crtxt_documents/31',
+ method: 'DELETE',
+ } );
+ expect( receiveEntityRecords ).toHaveBeenCalledWith(
+ 'postType',
+ DOCUMENT_POST_TYPE,
+ [ previous ],
+ undefined,
+ true
+ );
+ } );
+
+ it( 'caches response.post and returns the full restore response', async () => {
+ const receiveEntityRecords = jest.fn();
+ const post = { id: 31, status: 'private' };
+ const response = { restored: [ 31 ], post };
+ apiFetch.mockResolvedValue( response );
+
+ await expect(
+ restoreDocumentRecord( { id: 31 }, receiveEntityRecords )
+ ).resolves.toBe( response );
+
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ path: '/cortext/v1/documents/31/restore',
+ method: 'POST',
+ } );
+ expect( receiveEntityRecords ).toHaveBeenCalledWith(
+ 'postType',
+ DOCUMENT_POST_TYPE,
+ [ post ],
+ undefined,
+ true
+ );
+ } );
+
+ it( 'rejects without caching a record when the custom endpoint fails', async () => {
+ const receiveEntityRecords = jest.fn();
+ const error = new Error( 'duplicate failed' );
+ apiFetch.mockRejectedValue( error );
+
+ await expect(
+ duplicateDocumentRecord( { id: 12 }, receiveEntityRecords )
+ ).rejects.toBe( error );
+ expect( receiveEntityRecords ).not.toHaveBeenCalled();
+ } );
+} );
diff --git a/tests/php/test-rest-collections.php b/tests/php/test-rest-collections.php
index ea08c160..f31b1973 100644
--- a/tests/php/test-rest-collections.php
+++ b/tests/php/test-rest-collections.php
@@ -24,13 +24,16 @@
use Cortext\PostType\DocumentIdentity;
use Cortext\PostType\Field;
use Cortext\Rest\DocumentsController;
+use Cortext\Rest\RowsController;
use Cortext\Taxonomy\TraitTaxonomy;
use WorDBless\BaseTestCase;
+use WP_Error;
use WP_REST_Request;
use WP_REST_Server;
final class Test_Rest_Collections extends BaseTestCase {
+ use InMemoryPostsQuery;
use InMemoryTermStore;
public function set_up(): void {
@@ -49,13 +52,16 @@ public function set_up(): void {
( new Document() )->register_collection_meta();
$this->install_in_memory_term_store();
+ $this->install_in_memory_posts_query();
$GLOBALS['wp_rest_server'] = new WP_REST_Server();
( new DocumentsController() )->register();
+ ( new RowsController() )->register();
do_action( 'rest_api_init' );
}
public function tear_down(): void {
+ $this->uninstall_in_memory_posts_query();
$this->uninstall_in_memory_term_store();
wp_set_current_user( 0 );
@@ -182,9 +188,16 @@ public function test_duplicate_clones_schema_and_owner(): void {
$this->assertSame( 201, $response->get_status() );
$data = $response->get_data();
+ $this->assertSame(
+ array( 'id', 'title', 'slug', 'restBase', 'parent', 'collection_id', 'skipped_fields', 'post' ),
+ array_keys( $data )
+ );
$this->assertSame( 'Copy of Quarterly reports', $data['title'] );
$this->assertSame( $page_id, $data['parent'] );
$this->assertSame( array(), $data['skipped_fields'] );
+ $this->assertIsArray( $data['post'] );
+ $this->assertSame( $data['id'], $data['post']['id'] );
+ $this->assertSame( 'Copy of Quarterly reports', $data['post']['title']['raw'] );
$new_field_ids = $this->stored_collection_field_ids( (int) $data['id'] );
// The source's seeded "Title" placeholder is cloned alongside the
@@ -268,6 +281,73 @@ public function test_duplicate_remaps_rollup_references_to_cloned_field_ids(): v
);
}
+ public function test_duplicate_succeeds_when_canonical_post_cannot_be_prepared(): void {
+ wp_set_current_user( $this->create_user( 'administrator' ) );
+ $source = $this->create_page();
+
+ $fail_core_read = static function ( $response, $_handler, WP_REST_Request $request ) {
+ if (
+ 'GET' === $request->get_method()
+ && str_starts_with( $request->get_route(), '/wp/v2/crtxt_documents/' )
+ ) {
+ return new WP_Error( 'cortext_test_prepared_post_failed', 'Forced preparation failure.' );
+ }
+ return $response;
+ };
+ add_filter( 'rest_request_before_callbacks', $fail_core_read, 10, 3 );
+ try {
+ $response = $this->duplicate_collection( $source );
+ } finally {
+ remove_filter( 'rest_request_before_callbacks', $fail_core_read, 10 );
+ }
+
+ $this->assertSame( 201, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertGreaterThan( 0, $data['id'] );
+ $this->assertNull( $data['post'] );
+ $this->assertInstanceOf( \WP_Post::class, get_post( $data['id'] ) );
+ }
+
+ public function test_duplicate_row_returns_canonical_post_with_copied_hydrated_values(): void {
+ wp_set_current_user( $this->create_user( 'administrator' ) );
+
+ $collection_id = $this->create_collection( 'invoices', 'Invoices' );
+ $amount_id = $this->attach_scalar_field( $collection_id, 'Amount', 'number' );
+ $tags_id = $this->attach_scalar_field( $collection_id, 'Tags', 'multiselect' );
+ $row_id = $this->create_row( $collection_id, 'July invoice' );
+
+ update_post_meta( $row_id, 'field-' . $amount_id, '7.5' );
+ add_post_meta( $row_id, 'field-' . $tags_id, 'Paid' );
+ add_post_meta( $row_id, 'field-' . $tags_id, 'Priority' );
+
+ // Production init registers collection fields as document meta. Do the
+ // same here before dispatching the REST request.
+ ( new Document() )->register_field_meta();
+
+ $response = $this->duplicate_collection( $row_id );
+
+ $this->assertSame( 201, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertSame( $collection_id, $data['collection_id'] );
+ $this->assertIsArray( $data['post'] );
+ $this->assertSame( $data['id'], $data['post']['id'] );
+ $this->assertSame( 'Copy of July invoice', $data['post']['title']['raw'] );
+ $this->assertSame( 'private', $data['post']['status'] );
+ $this->assertSame( 7.5, $data['post']['meta'][ 'field-' . $amount_id ] );
+ $this->assertSame(
+ array( 'Paid', 'Priority' ),
+ $data['post']['meta'][ 'field-' . $tags_id ]
+ );
+ $this->assertSame(
+ 7.5,
+ $data['post']['cortext_hydrated_meta'][ 'field-' . $amount_id ]
+ );
+ $this->assertSame(
+ array( 'Paid', 'Priority' ),
+ $data['post']['cortext_hydrated_meta'][ 'field-' . $tags_id ]
+ );
+ }
+
public function test_duplicate_returns_404_for_unknown_id(): void {
wp_set_current_user( $this->create_user( 'administrator' ) );
@@ -319,6 +399,23 @@ private function attach_scalar_field( int $collection_id, string $title, string
return $field_id;
}
+ private function create_row( int $collection_id, string $title ): int {
+ $id = (int) wp_insert_post(
+ array(
+ 'post_type' => Document::POST_TYPE,
+ 'post_status' => 'private',
+ 'post_title' => $title,
+ )
+ );
+ $this->assertGreaterThan( 0, $id );
+
+ $term_id = TraitTaxonomy::term_id_for_trait( $collection_id );
+ $this->assertGreaterThan( 0, $term_id );
+ wp_set_object_terms( $id, array( $term_id ), TraitTaxonomy::TAXONOMY, false );
+
+ return $id;
+ }
+
private function create_user( string $role ): int {
return (int) wp_insert_user(
array(