@wordpress/core-data
Version:
Access to and manipulation of core WordPress entities.
1,009 lines (953 loc) • 27.7 kB
JavaScript
/**
* External dependencies
*/
import fastDeepEqual from 'fast-deep-equal/es6';
import { v4 as uuid } from 'uuid';
/**
* WordPress dependencies
*/
import apiFetch from '@wordpress/api-fetch';
import { addQueryArgs } from '@wordpress/url';
import deprecated from '@wordpress/deprecated';
/**
* Internal dependencies
*/
import { getNestedValue, setNestedValue } from './utils';
import { receiveItems, removeItems, receiveQueriedItems } from './queried-data';
import { DEFAULT_ENTITY_KEY } from './entities';
import { createBatch } from './batch';
import { STORE_NAME } from './name';
import { getSyncProvider } from './sync';
import logEntityDeprecation from './utils/log-entity-deprecation';
/**
* Returns an action object used in signalling that authors have been received.
* Ignored from documentation as it's internal to the data store.
*
* @ignore
*
* @param {string} queryID Query ID.
* @param {Array|Object} users Users received.
*
* @return {Object} Action object.
*/
export function receiveUserQuery( queryID, users ) {
return {
type: 'RECEIVE_USER_QUERY',
users: Array.isArray( users ) ? users : [ users ],
queryID,
};
}
/**
* Returns an action used in signalling that the current user has been received.
* Ignored from documentation as it's internal to the data store.
*
* @ignore
*
* @param {Object} currentUser Current user object.
*
* @return {Object} Action object.
*/
export function receiveCurrentUser( currentUser ) {
return {
type: 'RECEIVE_CURRENT_USER',
currentUser,
};
}
/**
* Returns an action object used in adding new entities.
*
* @param {Array} entities Entities received.
*
* @return {Object} Action object.
*/
export function addEntities( entities ) {
return {
type: 'ADD_ENTITIES',
entities,
};
}
/**
* Returns an action object used in signalling that entity records have been received.
*
* @param {string} kind Kind of the received entity record.
* @param {string} name Name of the received entity record.
* @param {Array|Object} records Records received.
* @param {?Object} query Query Object.
* @param {?boolean} invalidateCache Should invalidate query caches.
* @param {?Object} edits Edits to reset.
* @param {?Object} meta Meta information about pagination.
* @return {Object} Action object.
*/
export function receiveEntityRecords(
kind,
name,
records,
query,
invalidateCache = false,
edits,
meta
) {
// Auto drafts should not have titles, but some plugins rely on them so we can't filter this
// on the server.
if ( kind === 'postType' ) {
records = ( Array.isArray( records ) ? records : [ records ] ).map(
( record ) =>
record.status === 'auto-draft'
? { ...record, title: '' }
: record
);
}
let action;
if ( query ) {
action = receiveQueriedItems( records, query, edits, meta );
} else {
action = receiveItems( records, edits, meta );
}
return {
...action,
kind,
name,
invalidateCache,
};
}
/**
* Returns an action object used in signalling that the current theme has been received.
* Ignored from documentation as it's internal to the data store.
*
* @ignore
*
* @param {Object} currentTheme The current theme.
*
* @return {Object} Action object.
*/
export function receiveCurrentTheme( currentTheme ) {
return {
type: 'RECEIVE_CURRENT_THEME',
currentTheme,
};
}
/**
* Returns an action object used in signalling that the current global styles id has been received.
* Ignored from documentation as it's internal to the data store.
*
* @ignore
*
* @param {string} currentGlobalStylesId The current global styles id.
*
* @return {Object} Action object.
*/
export function __experimentalReceiveCurrentGlobalStylesId(
currentGlobalStylesId
) {
return {
type: 'RECEIVE_CURRENT_GLOBAL_STYLES_ID',
id: currentGlobalStylesId,
};
}
/**
* Returns an action object used in signalling that the theme base global styles have been received
* Ignored from documentation as it's internal to the data store.
*
* @ignore
*
* @param {string} stylesheet The theme's identifier
* @param {Object} globalStyles The global styles object.
*
* @return {Object} Action object.
*/
export function __experimentalReceiveThemeBaseGlobalStyles(
stylesheet,
globalStyles
) {
return {
type: 'RECEIVE_THEME_GLOBAL_STYLES',
stylesheet,
globalStyles,
};
}
/**
* Returns an action object used in signalling that the theme global styles variations have been received.
* Ignored from documentation as it's internal to the data store.
*
* @ignore
*
* @param {string} stylesheet The theme's identifier
* @param {Array} variations The global styles variations.
*
* @return {Object} Action object.
*/
export function __experimentalReceiveThemeGlobalStyleVariations(
stylesheet,
variations
) {
return {
type: 'RECEIVE_THEME_GLOBAL_STYLE_VARIATIONS',
stylesheet,
variations,
};
}
/**
* Returns an action object used in signalling that the index has been received.
*
* @deprecated since WP 5.9, this is not useful anymore, use the selector directly.
*
* @return {Object} Action object.
*/
export function receiveThemeSupports() {
deprecated( "wp.data.dispatch( 'core' ).receiveThemeSupports", {
since: '5.9',
} );
return {
type: 'DO_NOTHING',
};
}
/**
* Returns an action object used in signalling that the theme global styles CPT post revisions have been received.
* Ignored from documentation as it's internal to the data store.
*
* @deprecated since WordPress 6.5.0. Callers should use `dispatch( 'core' ).receiveRevision` instead.
*
* @ignore
*
* @param {number} currentId The post id.
* @param {Array} revisions The global styles revisions.
*
* @return {Object} Action object.
*/
export function receiveThemeGlobalStyleRevisions( currentId, revisions ) {
deprecated(
"wp.data.dispatch( 'core' ).receiveThemeGlobalStyleRevisions()",
{
since: '6.5.0',
alternative: "wp.data.dispatch( 'core' ).receiveRevisions",
}
);
return {
type: 'RECEIVE_THEME_GLOBAL_STYLE_REVISIONS',
currentId,
revisions,
};
}
/**
* Returns an action object used in signalling that the preview data for
* a given URl has been received.
* Ignored from documentation as it's internal to the data store.
*
* @ignore
*
* @param {string} url URL to preview the embed for.
* @param {*} preview Preview data.
*
* @return {Object} Action object.
*/
export function receiveEmbedPreview( url, preview ) {
return {
type: 'RECEIVE_EMBED_PREVIEW',
url,
preview,
};
}
/**
* Action triggered to delete an entity record.
*
* @param {string} kind Kind of the deleted entity.
* @param {string} name Name of the deleted entity.
* @param {number|string} recordId Record ID of the deleted entity.
* @param {?Object} query Special query parameters for the
* DELETE API call.
* @param {Object} [options] Delete options.
* @param {Function} [options.__unstableFetch] Internal use only. Function to
* call instead of `apiFetch()`.
* Must return a promise.
* @param {boolean} [options.throwOnError=false] If false, this action suppresses all
* the exceptions. Defaults to false.
*/
export const deleteEntityRecord =
(
kind,
name,
recordId,
query,
{ __unstableFetch = apiFetch, throwOnError = false } = {}
) =>
async ( { dispatch, resolveSelect } ) => {
logEntityDeprecation( kind, name, 'deleteEntityRecord' );
const configs = await resolveSelect.getEntitiesConfig( kind );
const entityConfig = configs.find(
( config ) => config.kind === kind && config.name === name
);
let error;
let deletedRecord = false;
if ( ! entityConfig ) {
return;
}
const lock = await dispatch.__unstableAcquireStoreLock(
STORE_NAME,
[ 'entities', 'records', kind, name, recordId ],
{ exclusive: true }
);
try {
dispatch( {
type: 'DELETE_ENTITY_RECORD_START',
kind,
name,
recordId,
} );
let hasError = false;
try {
let path = `${ entityConfig.baseURL }/${ recordId }`;
if ( query ) {
path = addQueryArgs( path, query );
}
deletedRecord = await __unstableFetch( {
path,
method: 'DELETE',
} );
await dispatch( removeItems( kind, name, recordId, true ) );
} catch ( _error ) {
hasError = true;
error = _error;
}
dispatch( {
type: 'DELETE_ENTITY_RECORD_FINISH',
kind,
name,
recordId,
error,
} );
if ( hasError && throwOnError ) {
throw error;
}
return deletedRecord;
} finally {
dispatch.__unstableReleaseStoreLock( lock );
}
};
/**
* Returns an action object that triggers an
* edit to an entity record.
*
* @param {string} kind Kind of the edited entity record.
* @param {string} name Name of the edited entity record.
* @param {number|string} recordId Record ID of the edited entity record.
* @param {Object} edits The edits.
* @param {Object} options Options for the edit.
* @param {boolean} [options.undoIgnore] Whether to ignore the edit in undo history or not.
*
* @return {Object} Action object.
*/
export const editEntityRecord =
( kind, name, recordId, edits, options = {} ) =>
( { select, dispatch } ) => {
logEntityDeprecation( kind, name, 'editEntityRecord' );
const entityConfig = select.getEntityConfig( kind, name );
if ( ! entityConfig ) {
throw new Error(
`The entity being edited (${ kind }, ${ name }) does not have a loaded config.`
);
}
const { mergedEdits = {} } = entityConfig;
const record = select.getRawEntityRecord( kind, name, recordId );
const editedRecord = select.getEditedEntityRecord(
kind,
name,
recordId
);
const edit = {
kind,
name,
recordId,
// Clear edits when they are equal to their persisted counterparts
// so that the property is not considered dirty.
edits: Object.keys( edits ).reduce( ( acc, key ) => {
const recordValue = record[ key ];
const editedRecordValue = editedRecord[ key ];
const value = mergedEdits[ key ]
? { ...editedRecordValue, ...edits[ key ] }
: edits[ key ];
acc[ key ] = fastDeepEqual( recordValue, value )
? undefined
: value;
return acc;
}, {} ),
};
if ( window.__experimentalEnableSync && entityConfig.syncConfig ) {
if ( globalThis.IS_GUTENBERG_PLUGIN ) {
const objectId = entityConfig.getSyncObjectId( recordId );
getSyncProvider().update(
entityConfig.syncObjectType + '--edit',
objectId,
edit.edits
);
}
} else {
if ( ! options.undoIgnore ) {
select.getUndoManager().addRecord(
[
{
id: { kind, name, recordId },
changes: Object.keys( edits ).reduce(
( acc, key ) => {
acc[ key ] = {
from: editedRecord[ key ],
to: edits[ key ],
};
return acc;
},
{}
),
},
],
options.isCached
);
}
dispatch( {
type: 'EDIT_ENTITY_RECORD',
...edit,
} );
}
};
/**
* Action triggered to undo the last edit to
* an entity record, if any.
*/
export const undo =
() =>
( { select, dispatch } ) => {
const undoRecord = select.getUndoManager().undo();
if ( ! undoRecord ) {
return;
}
dispatch( {
type: 'UNDO',
record: undoRecord,
} );
};
/**
* Action triggered to redo the last undone
* edit to an entity record, if any.
*/
export const redo =
() =>
( { select, dispatch } ) => {
const redoRecord = select.getUndoManager().redo();
if ( ! redoRecord ) {
return;
}
dispatch( {
type: 'REDO',
record: redoRecord,
} );
};
/**
* Forces the creation of a new undo level.
*
* @return {Object} Action object.
*/
export const __unstableCreateUndoLevel =
() =>
( { select } ) => {
select.getUndoManager().addRecord();
};
/**
* Action triggered to save an entity record.
*
* @param {string} kind Kind of the received entity.
* @param {string} name Name of the received entity.
* @param {Object} record Record to be saved.
* @param {Object} options Saving options.
* @param {boolean} [options.isAutosave=false] Whether this is an autosave.
* @param {Function} [options.__unstableFetch] Internal use only. Function to
* call instead of `apiFetch()`.
* Must return a promise.
* @param {boolean} [options.throwOnError=false] If false, this action suppresses all
* the exceptions. Defaults to false.
*/
export const saveEntityRecord =
(
kind,
name,
record,
{
isAutosave = false,
__unstableFetch = apiFetch,
throwOnError = false,
} = {}
) =>
async ( { select, resolveSelect, dispatch } ) => {
logEntityDeprecation( kind, name, 'saveEntityRecord' );
const configs = await resolveSelect.getEntitiesConfig( kind );
const entityConfig = configs.find(
( config ) => config.kind === kind && config.name === name
);
if ( ! entityConfig ) {
return;
}
const entityIdKey = entityConfig.key || DEFAULT_ENTITY_KEY;
const recordId = record[ entityIdKey ];
const lock = await dispatch.__unstableAcquireStoreLock(
STORE_NAME,
[ 'entities', 'records', kind, name, recordId || uuid() ],
{ exclusive: true }
);
try {
// Evaluate optimized edits.
// (Function edits that should be evaluated on save to avoid expensive computations on every edit.)
for ( const [ key, value ] of Object.entries( record ) ) {
if ( typeof value === 'function' ) {
const evaluatedValue = value(
select.getEditedEntityRecord( kind, name, recordId )
);
dispatch.editEntityRecord(
kind,
name,
recordId,
{
[ key ]: evaluatedValue,
},
{ undoIgnore: true }
);
record[ key ] = evaluatedValue;
}
}
dispatch( {
type: 'SAVE_ENTITY_RECORD_START',
kind,
name,
recordId,
isAutosave,
} );
let updatedRecord;
let error;
let hasError = false;
try {
const path = `${ entityConfig.baseURL }${
recordId ? '/' + recordId : ''
}`;
const persistedRecord = select.getRawEntityRecord(
kind,
name,
recordId
);
if ( isAutosave ) {
// Most of this autosave logic is very specific to posts.
// This is fine for now as it is the only supported autosave,
// but ideally this should all be handled in the back end,
// so the client just sends and receives objects.
const currentUser = select.getCurrentUser();
const currentUserId = currentUser
? currentUser.id
: undefined;
const autosavePost = await resolveSelect.getAutosave(
persistedRecord.type,
persistedRecord.id,
currentUserId
);
// Autosaves need all expected fields to be present.
// So we fallback to the previous autosave and then
// to the actual persisted entity if the edits don't
// have a value.
let data = {
...persistedRecord,
...autosavePost,
...record,
};
data = Object.keys( data ).reduce(
( acc, key ) => {
if (
[
'title',
'excerpt',
'content',
'meta',
].includes( key )
) {
acc[ key ] = data[ key ];
}
return acc;
},
{
// Do not update the `status` if we have edited it when auto saving.
// It's very important to let the user explicitly save this change,
// because it can lead to unexpected results. An example would be to
// have a draft post and change the status to publish.
status:
data.status === 'auto-draft'
? 'draft'
: undefined,
}
);
updatedRecord = await __unstableFetch( {
path: `${ path }/autosaves`,
method: 'POST',
data,
} );
// An autosave may be processed by the server as a regular save
// when its update is requested by the author and the post had
// draft or auto-draft status.
if ( persistedRecord.id === updatedRecord.id ) {
let newRecord = {
...persistedRecord,
...data,
...updatedRecord,
};
newRecord = Object.keys( newRecord ).reduce(
( acc, key ) => {
// These properties are persisted in autosaves.
if (
[ 'title', 'excerpt', 'content' ].includes(
key
)
) {
acc[ key ] = newRecord[ key ];
} else if ( key === 'status' ) {
// Status is only persisted in autosaves when going from
// "auto-draft" to "draft".
acc[ key ] =
persistedRecord.status ===
'auto-draft' &&
newRecord.status === 'draft'
? newRecord.status
: persistedRecord.status;
} else {
// These properties are not persisted in autosaves.
acc[ key ] = persistedRecord[ key ];
}
return acc;
},
{}
);
dispatch.receiveEntityRecords(
kind,
name,
newRecord,
undefined,
true
);
} else {
dispatch.receiveAutosaves(
persistedRecord.id,
updatedRecord
);
}
} else {
let edits = record;
if ( entityConfig.__unstablePrePersist ) {
edits = {
...edits,
...entityConfig.__unstablePrePersist(
persistedRecord,
edits
),
};
}
updatedRecord = await __unstableFetch( {
path,
method: recordId ? 'PUT' : 'POST',
data: edits,
} );
dispatch.receiveEntityRecords(
kind,
name,
updatedRecord,
undefined,
true,
edits
);
}
} catch ( _error ) {
hasError = true;
error = _error;
}
dispatch( {
type: 'SAVE_ENTITY_RECORD_FINISH',
kind,
name,
recordId,
error,
isAutosave,
} );
if ( hasError && throwOnError ) {
throw error;
}
return updatedRecord;
} finally {
dispatch.__unstableReleaseStoreLock( lock );
}
};
/**
* Runs multiple core-data actions at the same time using one API request.
*
* Example:
*
* ```
* const [ savedRecord, updatedRecord, deletedRecord ] =
* await dispatch( 'core' ).__experimentalBatch( [
* ( { saveEntityRecord } ) => saveEntityRecord( 'root', 'widget', widget ),
* ( { saveEditedEntityRecord } ) => saveEntityRecord( 'root', 'widget', 123 ),
* ( { deleteEntityRecord } ) => deleteEntityRecord( 'root', 'widget', 123, null ),
* ] );
* ```
*
* @param {Array} requests Array of functions which are invoked simultaneously.
* Each function is passed an object containing
* `saveEntityRecord`, `saveEditedEntityRecord`, and
* `deleteEntityRecord`.
*
* @return {(thunkArgs: Object) => Promise} A promise that resolves to an array containing the return
* values of each function given in `requests`.
*/
export const __experimentalBatch =
( requests ) =>
async ( { dispatch } ) => {
const batch = createBatch();
const api = {
saveEntityRecord( kind, name, record, options ) {
return batch.add( ( add ) =>
dispatch.saveEntityRecord( kind, name, record, {
...options,
__unstableFetch: add,
} )
);
},
saveEditedEntityRecord( kind, name, recordId, options ) {
return batch.add( ( add ) =>
dispatch.saveEditedEntityRecord( kind, name, recordId, {
...options,
__unstableFetch: add,
} )
);
},
deleteEntityRecord( kind, name, recordId, query, options ) {
return batch.add( ( add ) =>
dispatch.deleteEntityRecord( kind, name, recordId, query, {
...options,
__unstableFetch: add,
} )
);
},
};
const resultPromises = requests.map( ( request ) => request( api ) );
const [ , ...results ] = await Promise.all( [
batch.run(),
...resultPromises,
] );
return results;
};
/**
* Action triggered to save an entity record's edits.
*
* @param {string} kind Kind of the entity.
* @param {string} name Name of the entity.
* @param {Object} recordId ID of the record.
* @param {Object=} options Saving options.
*/
export const saveEditedEntityRecord =
( kind, name, recordId, options ) =>
async ( { select, dispatch, resolveSelect } ) => {
logEntityDeprecation( kind, name, 'saveEditedEntityRecord' );
if ( ! select.hasEditsForEntityRecord( kind, name, recordId ) ) {
return;
}
const configs = await resolveSelect.getEntitiesConfig( kind );
const entityConfig = configs.find(
( config ) => config.kind === kind && config.name === name
);
if ( ! entityConfig ) {
return;
}
const entityIdKey = entityConfig.key || DEFAULT_ENTITY_KEY;
const edits = select.getEntityRecordNonTransientEdits(
kind,
name,
recordId
);
const record = { [ entityIdKey ]: recordId, ...edits };
return await dispatch.saveEntityRecord( kind, name, record, options );
};
/**
* Action triggered to save only specified properties for the entity.
*
* @param {string} kind Kind of the entity.
* @param {string} name Name of the entity.
* @param {number|string} recordId ID of the record.
* @param {Array} itemsToSave List of entity properties or property paths to save.
* @param {Object} options Saving options.
*/
export const __experimentalSaveSpecifiedEntityEdits =
( kind, name, recordId, itemsToSave, options ) =>
async ( { select, dispatch, resolveSelect } ) => {
logEntityDeprecation(
kind,
name,
'__experimentalSaveSpecifiedEntityEdits'
);
if ( ! select.hasEditsForEntityRecord( kind, name, recordId ) ) {
return;
}
const edits = select.getEntityRecordNonTransientEdits(
kind,
name,
recordId
);
const editsToSave = {};
for ( const item of itemsToSave ) {
setNestedValue( editsToSave, item, getNestedValue( edits, item ) );
}
const configs = await resolveSelect.getEntitiesConfig( kind );
const entityConfig = configs.find(
( config ) => config.kind === kind && config.name === name
);
const entityIdKey = entityConfig?.key || DEFAULT_ENTITY_KEY;
// If a record key is provided then update the existing record.
// This necessitates providing `recordKey` to saveEntityRecord as part of the
// `record` argument (here called `editsToSave`) to stop that action creating
// a new record and instead cause it to update the existing record.
if ( recordId ) {
editsToSave[ entityIdKey ] = recordId;
}
return await dispatch.saveEntityRecord(
kind,
name,
editsToSave,
options
);
};
/**
* Returns an action object used in signalling that Upload permissions have been received.
*
* @deprecated since WP 5.9, use receiveUserPermission instead.
*
* @param {boolean} hasUploadPermissions Does the user have permission to upload files?
*
* @return {Object} Action object.
*/
export function receiveUploadPermissions( hasUploadPermissions ) {
deprecated( "wp.data.dispatch( 'core' ).receiveUploadPermissions", {
since: '5.9',
alternative: 'receiveUserPermission',
} );
return receiveUserPermission( 'create/media', hasUploadPermissions );
}
/**
* Returns an action object used in signalling that the current user has
* permission to perform an action on a REST resource.
* Ignored from documentation as it's internal to the data store.
*
* @ignore
*
* @param {string} key A key that represents the action and REST resource.
* @param {boolean} isAllowed Whether or not the user can perform the action.
*
* @return {Object} Action object.
*/
export function receiveUserPermission( key, isAllowed ) {
return {
type: 'RECEIVE_USER_PERMISSION',
key,
isAllowed,
};
}
/**
* Returns an action object used in signalling that the current user has
* permission to perform an action on a REST resource. Ignored from
* documentation as it's internal to the data store.
*
* @ignore
*
* @param {Object<string, boolean>} permissions An object where keys represent
* actions and REST resources, and
* values indicate whether the user
* is allowed to perform the
* action.
*
* @return {Object} Action object.
*/
export function receiveUserPermissions( permissions ) {
return {
type: 'RECEIVE_USER_PERMISSIONS',
permissions,
};
}
/**
* Returns an action object used in signalling that the autosaves for a
* post have been received.
* Ignored from documentation as it's internal to the data store.
*
* @ignore
*
* @param {number} postId The id of the post that is parent to the autosave.
* @param {Array|Object} autosaves An array of autosaves or singular autosave object.
*
* @return {Object} Action object.
*/
export function receiveAutosaves( postId, autosaves ) {
return {
type: 'RECEIVE_AUTOSAVES',
postId,
autosaves: Array.isArray( autosaves ) ? autosaves : [ autosaves ],
};
}
/**
* Returns an action object signalling that the fallback Navigation
* Menu id has been received.
*
* @param {integer} fallbackId the id of the fallback Navigation Menu
* @return {Object} Action object.
*/
export function receiveNavigationFallbackId( fallbackId ) {
return {
type: 'RECEIVE_NAVIGATION_FALLBACK_ID',
fallbackId,
};
}
/**
* Returns an action object used to set the template for a given query.
*
* @param {Object} query The lookup query.
* @param {string} templateId The resolved template id.
*
* @return {Object} Action object.
*/
export function receiveDefaultTemplateId( query, templateId ) {
return {
type: 'RECEIVE_DEFAULT_TEMPLATE',
query,
templateId,
};
}
/**
* Action triggered to receive revision items.
*
* @param {string} kind Kind of the received entity record revisions.
* @param {string} name Name of the received entity record revisions.
* @param {number|string} recordKey The key of the entity record whose revisions you want to fetch.
* @param {Array|Object} records Revisions received.
* @param {?Object} query Query Object.
* @param {?boolean} invalidateCache Should invalidate query caches.
* @param {?Object} meta Meta information about pagination.
*/
export const receiveRevisions =
( kind, name, recordKey, records, query, invalidateCache = false, meta ) =>
async ( { dispatch, resolveSelect } ) => {
logEntityDeprecation( kind, name, 'receiveRevisions' );
const configs = await resolveSelect.getEntitiesConfig( kind );
const entityConfig = configs.find(
( config ) => config.kind === kind && config.name === name
);
const key =
entityConfig && entityConfig?.revisionKey
? entityConfig.revisionKey
: DEFAULT_ENTITY_KEY;
dispatch( {
type: 'RECEIVE_ITEM_REVISIONS',
key,
items: Array.isArray( records ) ? records : [ records ],
recordKey,
meta,
query,
kind,
name,
invalidateCache,
} );
};