/
githubmirror
/
gutenberg
Обзор
Документация
Войти
/
githubmirror
/
gutenberg
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
trunk
packages/core-data/src/actions.js
1 215 строк
34 KB
Chris Zarate
Core Data: Fix post staying dirty after saving edited post meta with RTC enabled (#81392)
11 авг 2026, 11:34
Не верифицирован
11 авг 2026, 11:34
79e0ed0
Код
Авторство
О чём код?
import fastDeepEqual from 'fast-deep-equal/es6/index.js'; import { v4 as uuid } from 'uuid'; import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; import deprecated from '@wordpress/deprecated'; import { clearUnchangedEdits, 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 { CRDT_AUTOSAVE_SNAPSHOT_KEY, LOCAL_EDITOR_ORIGIN, LOCAL_UNDO_IGNORED_ORIGIN, getSyncManager, } from './sync'; import logEntityDeprecation from './utils/log-entity-deprecation'; import { getRawValue, POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE, } from './utils/crdt'; function addTitleToAutoDraft( record ) { return record.status === 'auto-draft' ? { ...record, title: '' } : record; } // Post meta is applied to the CRDT one subkey at a time, so compare the save // response at the same granularity to avoid carrying stale sibling values. function getServerMutatedMetaFields( updatedMeta, persistedMeta, syncedMeta ) { const baseline = { ...persistedMeta, ...syncedMeta }; return Object.fromEntries( Object.entries( updatedMeta ?? {} ).filter( ( [ key, value ] ) => { if ( key === POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ) { // The persisted CRDT snapshot may change on save and is // intentionally excluded from CRDT meta synchronization, so it // is not a server mutation. return false; } return ! fastDeepEqual( value, baseline[ key ] ); } ) ); } function getServerMutatedFields( updatedRecord, persistedRecord, syncedChanges ) { return Object.fromEntries( Object.entries( updatedRecord ).flatMap( ( [ key, value ] ) => { if ( key === 'meta' ) { const serverMutatedMeta = getServerMutatedMetaFields( value, persistedRecord.meta, syncedChanges.meta ); return Object.keys( serverMutatedMeta ).length ? [ [ key, serverMutatedMeta ] ] : []; } const baseline = key in syncedChanges ? syncedChanges[ key ] : persistedRecord[ key ]; // The save response nests raw attributes as `{ raw, rendered }` // while the baseline holds raw strings; compare raw values so the // shape difference does not read as a server mutation. const wasServerMutated = ! fastDeepEqual( getRawValue( value ) ?? value, getRawValue( baseline ) ?? baseline ); return wasServerMutated ? [ [ key, value ] ] : []; } ) ); } /** * 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 = undefined, invalidateCache = false, edits = undefined, meta = undefined ) { // 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.map( addTitleToAutoDraft ) : addTitleToAutoDraft( records ); } 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; let { baseURL } = entityConfig; if ( kind === 'postType' && name === 'wp_template' && ( ( recordId && typeof recordId === 'string' && ! /^\d+$/.test( recordId ) ) || ! window?.__experimentalTemplateActivate ) ) { baseURL = baseURL.slice( 0, baseURL.lastIndexOf( '/' ) ) + '/templates'; } try { let path = `${ baseURL }/${ recordId }`; if ( query ) { path = addQueryArgs( path, query ); } deletedRecord = await __unstableFetch( { path, method: 'DELETE', } ); await dispatch( removeItems( kind, name, recordId, true ) ); if ( entityConfig.syncConfig ) { const objectType = `${ kind }/${ name }`; const objectId = recordId; getSyncManager()?.unload( objectType, objectId ); } } 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. * @param {boolean} [options.isCached] Whether the edit is transient (e.g. typing). Transient * edits are staged and eventually merged into the * preceding undo level instead of creating a new one. * * @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 ); // Some fields are merged with the existing value instead of replaced. // See `mergedEdits` definition on the entity config. const editsWithMerges = Object.keys( edits ).reduce( ( acc, key ) => { acc[ key ] = mergedEdits[ key ] ? { ...editedRecord[ key ], ...edits[ key ] } : edits[ key ]; return acc; }, {} ); const edit = { kind, name, recordId, // Clear edits when they are equal to their persisted counterparts // so that the property is not considered dirty. edits: clearUnchangedEdits( editsWithMerges, record ), }; if ( entityConfig.syncConfig ) { const objectType = `${ kind }/${ name }`; const objectId = recordId; // Determine whether this edit should create a new undo level. // // In Gutenberg, block changes flow through two callbacks: // - `onInput`: For transient/in-progress changes (e.g., typing each // character). These use `isCached: true` and get merged into // the current undo item. // - `onChange`: For persistent/completed changes (e.g., formatting // transforms, block insertions). These use `isCached: false` and // should create a new undo level. // // Additionally, `undoIgnore: true` means the change should not // affect the undo history at all (e.g., selection-only changes). const isNewUndoLevel = options.undoIgnore ? false : ! options.isCached; // Use an untracked origin for undoIgnore changes so the Yjs // UndoManager does not capture them as undo levels, while // still syncing them to the CRDT document and other peers. const origin = options.undoIgnore ? LOCAL_UNDO_IGNORED_ORIGIN : LOCAL_EDITOR_ORIGIN; getSyncManager()?.update( objectType, objectId, editsWithMerges, origin, { isNewUndoLevel } ); } 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 clear all edits from * an entity record. * * @param {string} kind Kind of the entity. * @param {string} name Name of the entity. * @param {number|string} recordId Record ID of the entity record. * * @return {Object} Action object. */ export const clearEntityRecordEdits = ( kind, name, recordId ) => ( { select, dispatch } ) => { const entityConfig = select.getEntityConfig( kind, name ); logEntityDeprecation( kind, name, 'clearEntityRecordEdits' ); if ( ! entityConfig ) { throw new Error( `The entity being edited (${ kind }, ${ name }) does not have a loaded config.` ); } const currentEdits = select.getEntityRecordEdits( kind, name, recordId ); if ( ! currentEdits ) { return; } // Build an edits object with all current edit keys set to undefined // so the reducer removes them. const clearedEdits = Object.keys( currentEdits ).reduce( ( acc, key ) => { acc[ key ] = undefined; return acc; }, {} ); dispatch( { type: 'EDIT_ENTITY_RECORD', kind, name, recordId, edits: clearedEdits, } ); }; /** * 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, options = {} ) => async ( { select, resolveSelect, dispatch } ) => { const { isAutosave = false, __unstableFetch = apiFetch, __unstableSkipSyncUpdate = false, throwOnError = false, } = options; 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 isNewRecord = !! entityIdKey && ! recordId; 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; let { baseURL } = entityConfig; // For "string" IDs, use the old templates endpoint. if ( kind === 'postType' && name === 'wp_template' && ( ( recordId && typeof recordId === 'string' && ! /^\d+$/.test( recordId ) ) || ! window?.__experimentalTemplateActivate ) ) { baseURL = baseURL.slice( 0, baseURL.lastIndexOf( '/' ) ) + '/templates'; } try { const path = `${ baseURL }${ recordId ? '/' + recordId : '' }`; // Skip the raw values check when creating a new record; they don't exist yet. const persistedRecord = ! isNewRecord ? select.getRawEntityRecord( kind, name, recordId ) : {}; // `saveEntityRecord` can be called directly, bypassing // `editEntityRecord`, so make sure its changes enter the // CRDT first. An autosave snapshots the document below, and // a regular save creates the persisted document from it, so // both must see these changes. if ( entityConfig.syncConfig && ! __unstableSkipSyncUpdate && ! isNewRecord && persistedRecord ) { getSyncManager()?.update( `${ kind }/${ name }`, recordId, record, LOCAL_UNDO_IGNORED_ORIGIN ); } // 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. if ( isAutosave ) { // Build the autosave payload from the persisted // record and the incoming edits. The previous autosave // is intentionally excluded to avoid stale values // overriding reverted fields. const merged = { ...persistedRecord, ...record }; const data = [ 'title', 'excerpt', 'content', 'meta', ].reduce( ( acc, key ) => { if ( key in merged ) { acc[ key ] = merged[ 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: merged.status === 'auto-draft' ? 'draft' : undefined, } ); // Capture the CRDT snapshot in the same tick as the // payload so it describes exactly the content being // autosaved. if ( entityConfig.syncConfig ) { const crdtSnapshot = getSyncManager()?.getEntitySnapshot( `${ kind }/${ name }`, recordId ); if ( crdtSnapshot ) { data[ CRDT_AUTOSAVE_SNAPSHOT_KEY ] = crdtSnapshot; } } 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', 'meta', ].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, ...( await entityConfig.__unstablePrePersist( persistedRecord, edits ) ), }; } updatedRecord = await __unstableFetch( { path, method: recordId ? 'PUT' : 'POST', data: edits, } ); // Pass the pre-`__unstablePrePersist` edits so the reducer // can clear the persisted edits from state. The values // added by `__unstablePrePersist` (e.g. a fresh CRDT // snapshot in `meta`) never exist in the state edits, so // comparing against them would fail to match and leave the // record dirty after a successful save. dispatch.receiveEntityRecords( kind, name, updatedRecord, undefined, true, record ); if ( entityConfig.syncConfig ) { let syncChanges; if ( __unstableSkipSyncUpdate ) { syncChanges = {}; } else if ( isNewRecord || ! persistedRecord ) { syncChanges = updatedRecord; } else { syncChanges = getServerMutatedFields( updatedRecord, persistedRecord, record ); } // Use an untracked origin so that the save // response does not create undo levels. getSyncManager()?.update( `${ kind }/${ name }`, recordId, syncChanges, LOCAL_UNDO_IGNORED_ORIGIN, { isSave: true } ); } } } 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?.revisionKey ?? DEFAULT_ENTITY_KEY; dispatch( { type: 'RECEIVE_ITEM_REVISIONS', key, items: records, recordKey, meta, query, kind, name, invalidateCache, } ); };