From 7fb1df433a42f85a25826882623e3f2d7030ff43 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 14 Oct 2026 13:31:40 +0100 Subject: [PATCH 01/20] Core Data: Add a private selector for the actions registered on the server `getEntityActionsConfig( kind, name )` fetches the actions registered on the server for an entity from the `/wp/v2/entity-actions` route: the serializable part of each action, and the script modules providing their JavaScript parts. It mirrors `getFieldsConfig` of the fields loader, and is private while the place of that loader settles. It moves here from https://github.com/WordPress/gutenberg/pull/84650, which keeps the server code. --- docs/private-apis.md | 2 + packages/core-data/CHANGELOG.md | 1 + packages/core-data/src/private-actions.js | 18 ++++++++ packages/core-data/src/private-selectors.ts | 19 ++++++++ packages/core-data/src/reducer.js | 23 ++++++++++ packages/core-data/src/resolvers.js | 16 +++++++ packages/core-data/src/selectors.ts | 4 ++ .../src/test/private-selectors.jsdom.test.js | 28 ++++++++++- .../core-data/src/test/reducer.jsdom.test.js | 46 +++++++++++++++++++ .../src/test/resolvers.jsdom.test.js | 40 ++++++++++++++++ 10 files changed, 196 insertions(+), 1 deletion(-) diff --git a/docs/private-apis.md b/docs/private-apis.md index 9bb64cad4353fa..584c88b11dc800 100644 --- a/docs/private-apis.md +++ b/docs/private-apis.md @@ -199,10 +199,12 @@ Private exports: ### `core` store Private actions: +- `receiveEntityActionsConfig` - `receiveRegisteredPostMeta` - `editMediaEntity` Private selectors: +- `getEntityActionsConfig` - `getBlockPatternsForPostType` - `getEntityRecordPermissions` - `getEntityRecordsPermissions` diff --git a/packages/core-data/CHANGELOG.md b/packages/core-data/CHANGELOG.md index 81533d877d375c..f784eac36041fa 100644 --- a/packages/core-data/CHANGELOG.md +++ b/packages/core-data/CHANGELOG.md @@ -13,6 +13,7 @@ ### Internal - Add the private `getFieldsConfig( kind, name )` selector, which returns the fields an entity registered on the server, as the `/wp/v2/entity-fields` REST API route returns them ([#84700](https://github.com/WordPress/gutenberg/pull/84700)). +- Add the private `getEntityActionsConfig( kind, name )` selector, which fetches the actions registered on the server for an entity from the `/wp/v2/entity-actions` route ([#84660](https://github.com/WordPress/gutenberg/pull/84660)). ## 8.2.0 (2026-10-07) diff --git a/packages/core-data/src/private-actions.js b/packages/core-data/src/private-actions.js index 2d72b574aabea3..483596768f076b 100644 --- a/packages/core-data/src/private-actions.js +++ b/packages/core-data/src/private-actions.js @@ -213,6 +213,24 @@ export function receiveFieldsConfig( kind, name, config ) { }; } +/** + * Returns an action object used to receive the actions config of an entity. + * + * @param {string} kind Entity kind. + * @param {string} name Entity name. + * @param {Object} config Actions config, as returned by the `/wp/v2/entity-actions` route. + * + * @return {Object} Action object. + */ +export function receiveEntityActionsConfig( kind, name, config ) { + return { + type: 'RECEIVE_ENTITY_ACTIONS_CONFIG', + kind, + name, + config, + }; +} + /** * Records that the entity sync manager opened a new undo level for a record * it syncs, so the level takes its place in core-data's undo history next to diff --git a/packages/core-data/src/private-selectors.ts b/packages/core-data/src/private-selectors.ts index 09e97c4af7d7be..6025dd447f36a9 100644 --- a/packages/core-data/src/private-selectors.ts +++ b/packages/core-data/src/private-selectors.ts @@ -370,6 +370,25 @@ export function getFieldsConfig( return state.fieldsConfigs?.[ kind ]?.[ name ]; } +/** + * Returns the actions registered on the server for the given entity, as the + * `/wp/v2/entity-actions` REST API route returns them: the serializable part of each + * action, and the script modules providing their JavaScript parts. + * + * @param state Data state. + * @param kind Entity kind. + * @param name Entity name. + * + * @return The actions config, or undefined if not loaded. + */ +export function getEntityActionsConfig( + state: State, + kind: string, + name: string +): Record< string, any > | undefined { + return state.entityActionsConfigs?.[ kind ]?.[ name ]; +} + /** * Returns the current sync connection status across all entities. Prioritizes * disconnected states, then connecting, then connected. diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 9844e9ee119cc5..ab241c188dc9fd 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -774,6 +774,28 @@ export function fieldsConfigs( state = {}, action ) { return state; } +/** + * Reducer managing the actions configs, keyed by kind, then name. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function entityActionsConfigs( state = {}, action ) { + switch ( action.type ) { + case 'RECEIVE_ENTITY_ACTIONS_CONFIG': + return { + ...state, + [ action.kind ]: { + ...state[ action.kind ], + [ action.name ]: action.config, + }, + }; + } + return state; +} + export default combineReducers( { users, currentTheme, @@ -801,4 +823,5 @@ export default combineReducers( { collaborationSupported, viewConfigs, fieldsConfigs, + entityActionsConfigs, } ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index b41a69a60eea01..066e2d4972b7db 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -1240,3 +1240,19 @@ export const getFieldsConfig = } ); dispatch.receiveFieldsConfig( kind, name, config ); }; + +/** + * Requests the actions registered on the server for a given entity from the + * REST API. + * + * @param {string} kind Entity kind. + * @param {string} name Entity name. + */ +export const getEntityActionsConfig = + ( kind, name ) => + async ( { dispatch } ) => { + const config = await apiFetch( { + path: addQueryArgs( '/wp/v2/entity-actions', { kind, name } ), + } ); + dispatch.receiveEntityActionsConfig( kind, name, config ); + }; diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 273d6cb345b8b1..8dcea4dcc9c4f8 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -51,6 +51,10 @@ export interface State { collaborationSupported: boolean; viewConfigs: Record< string, Record< string, any > >; fieldsConfigs: Record< string, Record< string, FieldsConfig > >; + entityActionsConfigs: Record< + string, + Record< string, Record< string, any > > + >; } type EntityRecordKey = string | number; diff --git a/packages/core-data/src/test/private-selectors.jsdom.test.js b/packages/core-data/src/test/private-selectors.jsdom.test.js index e4120d2c045643..07e28abac57f24 100644 --- a/packages/core-data/src/test/private-selectors.jsdom.test.js +++ b/packages/core-data/src/test/private-selectors.jsdom.test.js @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import deepFreeze from 'deep-freeze'; -import { getFieldsConfig, getUndoManager } from '../private-selectors'; +import { + getEntityActionsConfig, + getFieldsConfig, + getUndoManager, +} from '../private-selectors'; describe( 'getUndoManager', () => { it( 'returns the undo manager held in state', () => { @@ -33,3 +37,25 @@ describe( 'getFieldsConfig', () => { expect( getFieldsConfig( state, 'postType', 'post' ) ).toBeUndefined(); } ); } ); + +describe( 'getEntityActionsConfig', () => { + it( 'returns undefined until the actions of the entity are received', () => { + const state = { entityActionsConfigs: {} }; + + expect( + getEntityActionsConfig( state, 'postType', 'page' ) + ).toBeUndefined(); + } ); + + it( 'returns the received actions of the entity', () => { + const config = { kind: 'postType', name: 'page', actions: [] }; + const state = { entityActionsConfigs: { postType: { page: config } } }; + + expect( getEntityActionsConfig( state, 'postType', 'page' ) ).toBe( + config + ); + expect( + getEntityActionsConfig( state, 'postType', 'post' ) + ).toBeUndefined(); + } ); +} ); diff --git a/packages/core-data/src/test/reducer.jsdom.test.js b/packages/core-data/src/test/reducer.jsdom.test.js index c4c602769660ed..4243e169cade42 100644 --- a/packages/core-data/src/test/reducer.jsdom.test.js +++ b/packages/core-data/src/test/reducer.jsdom.test.js @@ -9,6 +9,7 @@ import { undoManagerReference, undoManager, fieldsConfigs, + entityActionsConfigs, } from '../reducer'; describe( 'entities', () => { @@ -601,3 +602,48 @@ describe( 'fieldsConfigs', () => { expect( state.a[ 'b/c' ].fields ).toEqual( [ { id: 'second' } ] ); } ); } ); + +describe( 'entityActionsConfigs', () => { + it( 'returns an empty object by default', () => { + expect( entityActionsConfigs( undefined, {} ) ).toEqual( {} ); + } ); + + it( 'keys the received configs by kind, then name', () => { + const pageConfig = { kind: 'postType', name: 'page', actions: [] }; + const postConfig = { kind: 'postType', name: 'post', actions: [] }; + const state = deepFreeze( { + postType: { page: pageConfig }, + } ); + + expect( + entityActionsConfigs( state, { + type: 'RECEIVE_ENTITY_ACTIONS_CONFIG', + kind: 'postType', + name: 'post', + config: postConfig, + } ) + ).toEqual( { + postType: { page: pageConfig, post: postConfig }, + } ); + } ); + + it( 'keeps entities whose kind and name join into the same string apart', () => { + const state = entityActionsConfigs( + entityActionsConfigs( undefined, { + type: 'RECEIVE_ENTITY_ACTIONS_CONFIG', + kind: 'a/b', + name: 'c', + config: { actions: [ { id: 'first' } ] }, + } ), + { + type: 'RECEIVE_ENTITY_ACTIONS_CONFIG', + kind: 'a', + name: 'b/c', + config: { actions: [ { id: 'second' } ] }, + } + ); + + expect( state[ 'a/b' ].c.actions ).toEqual( [ { id: 'first' } ] ); + expect( state.a[ 'b/c' ].actions ).toEqual( [ { id: 'second' } ] ); + } ); +} ); diff --git a/packages/core-data/src/test/resolvers.jsdom.test.js b/packages/core-data/src/test/resolvers.jsdom.test.js index 2be2fa6fcae1b7..b87c1b7a27cbf9 100644 --- a/packages/core-data/src/test/resolvers.jsdom.test.js +++ b/packages/core-data/src/test/resolvers.jsdom.test.js @@ -9,6 +9,7 @@ import { getAutosaves, getCurrentUser, getFieldsConfig, + getEntityActionsConfig, } from '../resolvers'; import { RECEIVE_INTERMEDIATE_RESULTS } from '../utils'; vi.mock( import( '@wordpress/api-fetch' ) ); @@ -1139,3 +1140,42 @@ describe( 'getFieldsConfig', () => { expect( dispatch.receiveFieldsConfig ).not.toHaveBeenCalled(); } ); } ); + +describe( 'getEntityActionsConfig', () => { + it( 'requests the actions of the entity and receives them', async () => { + const config = { + kind: 'postType', + name: 'page', + actions: [ { id: 'rename-post' } ], + script_modules: [], + }; + const dispatch = Object.assign( vi.fn(), { + receiveEntityActionsConfig: vi.fn(), + } ); + triggerFetch.mockResolvedValue( config ); + + await getEntityActionsConfig( 'postType', 'page' )( { dispatch } ); + + expect( triggerFetch ).toHaveBeenCalledWith( { + path: '/wp/v2/entity-actions?kind=postType&name=page', + } ); + expect( dispatch.receiveEntityActionsConfig ).toHaveBeenCalledWith( + 'postType', + 'page', + config + ); + } ); + + it( 'rejects without receiving anything when the request fails', async () => { + const dispatch = Object.assign( vi.fn(), { + receiveEntityActionsConfig: vi.fn(), + } ); + const error = { code: 'rest_forbidden', message: 'Forbidden.' }; + triggerFetch.mockRejectedValue( error ); + + await expect( + getEntityActionsConfig( 'postType', 'page' )( { dispatch } ) + ).rejects.toBe( error ); + expect( dispatch.receiveEntityActionsConfig ).not.toHaveBeenCalled(); + } ); +} ); From c84e8228b79cf15765cc34500e850be0d4cd6398 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 14 Oct 2026 13:31:41 +0100 Subject: [PATCH 02/20] Editor: Show the actions registered on the server `usePostActions()` shows the actions registered on the server for the post type after the actions of the `core/editor` store: a private `useEntityActions()` hook reads them with `getEntityActionsConfig`, imports the script modules the server lists, and merges their JavaScript parts into the actions, in the server order. A module only completes the actions it was registered with, keyed by id, and an action left without a `callback` or a `RenderModal` is skipped, as is an action whose id the store already holds. When the actions cannot be loaded, the hook warns with the error. It mirrors `useFields` of the fields loader, and lives in the editor until the place of that loader settles. It moves here from https://github.com/WordPress/gutenberg/pull/84650, which keeps the server code. --- packages/editor/CHANGELOG.md | 4 + .../src/components/post-actions/actions.jsx | 16 +- .../src/dataviews/entity-actions-loader.ts | 219 +++++++++++++++ .../test/entity-actions-loader.jsdom.test.tsx | 257 ++++++++++++++++++ 4 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 packages/editor/src/dataviews/entity-actions-loader.ts create mode 100644 packages/editor/src/dataviews/test/entity-actions-loader.jsdom.test.tsx diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 021da0748d5d4d..896f6ab9797a21 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -7,6 +7,10 @@ - Raise the minimum required Node.js version to `>=22.13.0`, for stable `require(esm)` support ([#84313](https://github.com/WordPress/gutenberg/pull/84313)). - The editor stylesheet no longer includes the styles of `@wordpress/fields`. A plugin that renders `MediaEdit` loads `@wordpress/fields/build-style/style.css` with its own script, as the package README describes ([#84897](https://github.com/WordPress/gutenberg/pull/84897)). +### New Features + +- Show the actions registered on the server for a post type after its other actions, in the lists of the site editor and in the inspector. Plugins register them in PHP on the `wp_entity_actions_api_init` action, with a script module for their JavaScript parts ([#84660](https://github.com/WordPress/gutenberg/pull/84660)). + ### Enhancements - The post summary of the DataForm-based inspector, the media editor, and the private `usePostFields` hook, through which the site editor screens get their fields, read the fields of the post type from the server, through the Entity fields API. The media editor also lays out the attachment fields with the form of the attachment view configuration ([#84700](https://github.com/WordPress/gutenberg/pull/84700)). diff --git a/packages/editor/src/components/post-actions/actions.jsx b/packages/editor/src/components/post-actions/actions.jsx index 6a2d4b6a291ba2..b8950807520b1e 100644 --- a/packages/editor/src/components/post-actions/actions.jsx +++ b/packages/editor/src/components/post-actions/actions.jsx @@ -3,6 +3,7 @@ import { useMemo, useEffect } from '@wordpress/element'; import { store as coreStore } from '@wordpress/core-data'; import { store as editorStore } from '../../store'; import { unlock } from '../../lock-unlock'; +import { useEntityActions } from '../../dataviews/entity-actions-loader'; import { useSetAsHomepageAction } from './set-as-homepage'; import { useSetAsPostsPageAction } from './set-as-posts-page'; @@ -16,6 +17,10 @@ export function usePostActions( { postType, onActionPerformed, context } ) { }, [ postType ] ); + const serverActions = useEntityActions( { + kind: 'postType', + name: postType, + } ); const shouldShowHomepageActions = useSelect( ( select ) => { @@ -73,7 +78,15 @@ export function usePostActions( { postType, onActionPerformed, context } ) { }, [ registerPostTypeSchema, postType ] ); return useMemo( () => { - let actions = [ ...defaultActions ]; + // The actions registered on the server follow the ones of the store, + // and cannot take their ids. + let actions = [ + ...defaultActions, + ...serverActions.filter( + ( action ) => + ! defaultActions.some( ( { id } ) => id === action.id ) + ), + ]; if ( shouldShowHomepageActions ) { actions.push( setAsHomepageAction, setAsPostsPageAction ); } @@ -148,6 +161,7 @@ export function usePostActions( { postType, onActionPerformed, context } ) { context, defaultActions, onActionPerformed, + serverActions, setAsHomepageAction, setAsPostsPageAction, shouldShowHomepageActions, diff --git a/packages/editor/src/dataviews/entity-actions-loader.ts b/packages/editor/src/dataviews/entity-actions-loader.ts new file mode 100644 index 00000000000000..a7bf7d4d61dbb1 --- /dev/null +++ b/packages/editor/src/dataviews/entity-actions-loader.ts @@ -0,0 +1,219 @@ +/** + * Loads the actions registered on the server for an entity. + * + * It mirrors `useFields` of the fields loader + * (https://github.com/WordPress/gutenberg/pull/84323), and is temporary: it + * lives in the editor, which every screen showing actions loads, until the + * place of the fields loader settles. Then it moves next to `useFields`. + */ + +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { useEffect, useState } from '@wordpress/element'; +import type { Action } from '@wordpress/dataviews'; +import { unlock } from '../lock-unlock'; + +/** + * The actions registered on the server for an entity, as returned by the + * `/wp/v2/entity-actions` route. + */ +export interface EntityActionsConfig { + kind: string; + name: string; + /** + * The serializable part of each action, in registration order. + */ + actions: Array< { id: string } & Record< string, any > >; + /** + * The script modules providing the JavaScript parts of the actions, in + * registration order, with the ids of the actions each provides. + */ + script_modules: Array< { id: string; actions: string[] } >; +} + +/** + * The JavaScript parts of the actions a script module provides, keyed by + * action id: what PHP cannot serialize, such as `callback`, `RenderModal`, + * or `isEligible`. + * + * It is the shape of the default export of a script module registered along + * with actions on the server. + */ +type EntityActionsScriptParts = Record< string, Record< string, unknown > >; + +type ModuleImporter = ( id: string ) => Promise< unknown >; + +const EMPTY_ACTIONS: Action< any >[] = []; + +function importModule( id: string ) { + return import( /* webpackIgnore: true */ /* @vite-ignore */ id ); +} + +/** + * Imports the script modules of an actions config and merges their + * JavaScript parts into the actions. + * + * The modules are imported in parallel, but applied in the order the server + * lists them once all have settled, so a later module overrides an earlier + * one no matter which finishes loading first. A module only contributes to + * the actions the server lists for it: a module can hold actions that were + * never registered, or were unregistered, and those must not show up. + * + * An action is performed by its `callback` or its `RenderModal`, which only + * a script module provides, so an action left without both is skipped: when + * its module fails to load, which is reported, or when no module provides + * them. + * + * @param config The actions config. + * @param importer How to import a script module by id. + * @return The actions, in the server order. + */ +export async function resolveEntityActionsConfig< Item >( + config: EntityActionsConfig, + importer: ModuleImporter = importModule +): Promise< Action< Item >[] > { + const modules = config.script_modules ?? []; + const results = await Promise.allSettled( + modules.map( ( { id } ) => importer( id ) ) + ); + + const partsById = new Map< string, EntityActionsScriptParts[ string ] >(); + const failedIds = new Set< string >(); + results.forEach( ( result, index ) => { + const { id: moduleId, actions: actionIds } = modules[ index ]; + if ( result.status === 'rejected' ) { + // eslint-disable-next-line no-console + console.warn( + `Could not load the script module ${ moduleId } of the actions of ${ config.kind }/${ config.name }.`, + result.reason + ); + ( actionIds ?? [] ).forEach( ( actionId ) => + failedIds.add( actionId ) + ); + return; + } + const parts = + ( result.value as { default?: EntityActionsScriptParts } ) + ?.default ?? {}; + for ( const actionId of actionIds ?? [] ) { + if ( parts[ actionId ] ) { + partsById.set( actionId, { + ...partsById.get( actionId ), + ...parts[ actionId ], + } ); + } + } + } ); + + return ( config.actions ?? [] ).flatMap( ( definition ) => { + const action: Record< string, any > = { + ...definition, + ...partsById.get( definition.id ), + // The server identifies the action, not its module. + id: definition.id, + }; + if ( action.callback || action.RenderModal ) { + return [ action as Action< Item > ]; + } + if ( ! failedIds.has( action.id ) ) { + // eslint-disable-next-line no-console + console.warn( + `The action ${ action.id } of ${ config.kind }/${ config.name } is skipped: no script module provides its \`callback\` or \`RenderModal\`.` + ); + } + return []; + } ); +} + +/* + * One resolution per actions config the store holds, so every caller gets the + * same action objects and each module is applied once. + */ +const resolvedEntityActionsConfigs = new WeakMap< + EntityActionsConfig, + Promise< Action< any >[] > +>(); + +function getResolvedActions< Item >( + config: EntityActionsConfig +): Promise< Action< Item >[] > { + let resolved = resolvedEntityActionsConfigs.get( config ); + if ( ! resolved ) { + resolved = resolveEntityActionsConfig( config ); + resolvedEntityActionsConfigs.set( config, resolved ); + } + return resolved; +} + +/** + * Returns the actions registered on the server for an entity. + * + * Requests the actions from the `/wp/v2/entity-actions` route through the core data + * store, imports the script modules registered along with them, and merges + * their JavaScript parts in. + * + * @param entity The entity. + * @param entity.kind Entity kind (e.g. `postType`). + * @param entity.name Entity name (e.g. `page`). + * @return The actions, in the server order. Empty until they load, or when + * they cannot be requested, which is reported. + */ +export function useEntityActions< Item = any >( { + kind, + name, +}: { + kind: string; + name: string; +} ): Action< Item >[] { + const { config, error } = useSelect( + ( select ) => { + const { getEntityActionsConfig, getResolutionError } = unlock( + select( coreStore ) + ); + return { + config: getEntityActionsConfig( kind, name ) as + EntityActionsConfig | undefined, + error: getResolutionError( 'getEntityActionsConfig', [ + kind, + name, + ] ) as unknown, + }; + }, + [ kind, name ] + ); + + useEffect( () => { + if ( error ) { + // eslint-disable-next-line no-console + console.warn( + `Could not load the actions of ${ kind }/${ name }.`, + error + ); + } + }, [ error, kind, name ] ); + + const [ resolved, setResolved ] = useState< { + config: EntityActionsConfig; + actions: Action< Item >[]; + } >(); + + useEffect( () => { + if ( ! config ) { + return; + } + let isCurrent = true; + getResolvedActions< Item >( config ).then( ( actions ) => { + if ( isCurrent ) { + setResolved( { config, actions } ); + } + } ); + return () => { + isCurrent = false; + }; + }, [ config ] ); + + if ( ! config || resolved?.config !== config ) { + return EMPTY_ACTIONS; + } + return resolved.actions; +} diff --git a/packages/editor/src/dataviews/test/entity-actions-loader.jsdom.test.tsx b/packages/editor/src/dataviews/test/entity-actions-loader.jsdom.test.tsx new file mode 100644 index 00000000000000..e25dbd225ac5bb --- /dev/null +++ b/packages/editor/src/dataviews/test/entity-actions-loader.jsdom.test.tsx @@ -0,0 +1,257 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import apiFetch from '@wordpress/api-fetch'; +import { createRegistry, RegistryProvider } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { + resolveEntityActionsConfig, + useEntityActions, +} from '../entity-actions-loader'; + +vi.mock( import( '@wordpress/api-fetch' ) ); + +const featureCallback = vi.fn(); + +// The script module the `useEntityActions` tests import. It only exists as this +// mock, so it is mocked for the dynamic import of the loader with +// `vi.doMock()`: `vi.mock( import() )` needs a module TypeScript resolves. +vi.doMock( 'acme/actions', () => ( { + default: { 'acme/feature': { callback: featureCallback } }, +} ) ); + +const CORE_MODULE = '@wordpress/core-actions/post_type_supports'; + +const CONFIG = { + kind: 'postType', + name: 'page', + actions: [ + { id: 'rename-post', label: 'Rename…' }, + { id: 'acme/feature', label: 'Feature', supportsBulk: true }, + { id: 'move-to-trash', label: 'Trash…', isPrimary: true }, + ], + script_modules: [ + { id: CORE_MODULE, actions: [ 'rename-post', 'move-to-trash' ] }, + { id: 'acme/actions', actions: [ 'acme/feature', 'move-to-trash' ] }, + ], +}; + +function RenameModal() { + return null; +} + +function TrashModal() { + return null; +} + +function deferred< T >() { + let resolve!: ( value: T ) => void; + const promise = new Promise< T >( ( _resolve ) => { + resolve = _resolve; + } ); + return { promise, resolve }; +} + +describe( 'resolveEntityActionsConfig', () => { + it( 'keeps the server order and merges the parts of each action in', async () => { + const callback = vi.fn(); + const actions = await resolveEntityActionsConfig( + CONFIG, + async ( id ) => + id === CORE_MODULE + ? { + default: { + 'rename-post': { RenderModal: RenameModal }, + 'move-to-trash': { RenderModal: TrashModal }, + }, + } + : { default: { 'acme/feature': { callback } } } + ); + + expect( actions ).toEqual( [ + { id: 'rename-post', label: 'Rename…', RenderModal: RenameModal }, + { + id: 'acme/feature', + label: 'Feature', + supportsBulk: true, + callback, + }, + { + id: 'move-to-trash', + label: 'Trash…', + isPrimary: true, + RenderModal: TrashModal, + }, + ] ); + } ); + + it( 'applies the modules in the server order, whichever loads first', async () => { + const coreIsEligible = () => true; + const pluginIsEligible = () => false; + const core = deferred< unknown >(); + const plugin = deferred< unknown >(); + const resolving = resolveEntityActionsConfig( CONFIG, ( id ) => + id === CORE_MODULE ? core.promise : plugin.promise + ); + + // The plugin module finishes first; core, listed earlier, must not + // override it when it finishes last. + plugin.resolve( { + default: { + 'acme/feature': { callback: vi.fn() }, + 'move-to-trash': { isEligible: pluginIsEligible }, + }, + } ); + core.resolve( { + default: { + 'rename-post': { RenderModal: RenameModal }, + 'move-to-trash': { + RenderModal: TrashModal, + isEligible: coreIsEligible, + }, + }, + } ); + + const actions = await resolving; + expect( actions[ 2 ] ).toMatchObject( { + id: 'move-to-trash', + RenderModal: TrashModal, + isEligible: pluginIsEligible, + } ); + } ); + + it( 'ignores the parts of actions the server does not list for the module', async () => { + const actions = await resolveEntityActionsConfig( + { + ...CONFIG, + script_modules: [ + { + id: CORE_MODULE, + actions: [ 'rename-post', 'acme/feature' ], + }, + ], + }, + async () => ( { + default: { + 'rename-post': { RenderModal: RenameModal }, + 'acme/feature': { callback: vi.fn() }, + 'move-to-trash': { RenderModal: TrashModal }, + 'duplicate-post': { RenderModal: RenameModal }, + }, + } ) + ); + + expect( actions.map( ( { id } ) => id ) ).toEqual( [ + 'rename-post', + 'acme/feature', + ] ); + expect( console ).toHaveWarnedWith( + 'The action move-to-trash of postType/page is skipped: no script module provides its `callback` or `RenderModal`.' + ); + } ); + + it( 'keeps the id the server registered the action with', async () => { + const actions = await resolveEntityActionsConfig( + { + ...CONFIG, + actions: [ CONFIG.actions[ 0 ] ], + script_modules: [ + { id: CORE_MODULE, actions: [ 'rename-post' ] }, + ], + }, + async () => ( { + default: { + 'rename-post': { id: 'retitle', RenderModal: RenameModal }, + }, + } ) + ); + + expect( actions.map( ( { id } ) => id ) ).toEqual( [ 'rename-post' ] ); + } ); + + it( 'skips the actions whose module fails to load', async () => { + const callback = vi.fn(); + const actions = await resolveEntityActionsConfig( + CONFIG, + async ( id ) => { + if ( id === CORE_MODULE ) { + throw new Error( 'Network error' ); + } + return { default: { 'acme/feature': { callback } } }; + } + ); + + expect( actions ).toEqual( [ + { + id: 'acme/feature', + label: 'Feature', + supportsBulk: true, + callback, + }, + ] ); + // The module is reported, not each action it leaves without parts. + expect( console ).toHaveWarnedWith( + `Could not load the script module ${ CORE_MODULE } of the actions of postType/page.`, + expect.any( Error ) + ); + } ); +} ); + +describe( 'useEntityActions', () => { + function renderUseActions() { + const registry = createRegistry(); + registry.register( coreStore ); + return renderHook( + () => useEntityActions( { kind: 'postType', name: 'page' } ), + { + wrapper: ( { children } ) => ( + + { children } + + ), + } + ); + } + + it( 'returns the actions once they and their script modules load', async () => { + vi.mocked( apiFetch ).mockResolvedValue( { + ...CONFIG, + actions: [ CONFIG.actions[ 1 ] ], + script_modules: [ + { id: 'acme/actions', actions: [ 'acme/feature' ] }, + ], + } ); + + const { result } = renderUseActions(); + + expect( result.current ).toEqual( [] ); + await waitFor( () => expect( result.current ).toHaveLength( 1 ) ); + expect( result.current[ 0 ] ).toEqual( { + id: 'acme/feature', + label: 'Feature', + supportsBulk: true, + callback: featureCallback, + } ); + expect( apiFetch ).toHaveBeenCalledWith( { + path: '/wp/v2/entity-actions?kind=postType&name=page', + } ); + } ); + + it( 'returns no actions and reports it when the request fails', async () => { + const error = { + code: 'rest_forbidden', + message: 'Sorry, you are not allowed to do that.', + data: { status: 403 }, + }; + vi.mocked( apiFetch ).mockRejectedValue( error ); + + const { result } = renderUseActions(); + + await waitFor( () => + expect( console ).toHaveWarnedWith( + 'Could not load the actions of postType/page.', + error + ) + ); + expect( result.current ).toEqual( [] ); + } ); +} ); From 1c595eabc2e5e4b1b1fcf4bd9a31859a8db74b1e Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 14 Oct 2026 13:31:42 +0100 Subject: [PATCH 03/20] Actions API: Preload the actions of the post type the post editor edits The actions menu of the document sidebar reads the actions of the post type from `/wp/v2/entity-actions` as the post editor loads, a request the post editor did not make on startup before. It is preloaded, and the kickoff of the post editor resolves `getEntityActionsConfig`, so the preload is consumed before the editor mounts, like the other startup requests. The site editor makes the request too when it opens a page, along with other requests it does not preload yet. Preloading the actions of its screens follows the way the fields of these screens get preloaded. It moves here from https://github.com/WordPress/gutenberg/pull/84650, which keeps the server code. --- .../wordpress-7.2/entity-actions-api.php | 30 ++++++++++++++ packages/edit-post/src/index.jsx | 10 +++++ phpunit/entity-actions-api-test.php | 39 +++++++++++++++++++ test/e2e/specs/preload/site-editor.spec.js | 1 + 4 files changed, 80 insertions(+) diff --git a/lib/compat/wordpress-7.2/entity-actions-api.php b/lib/compat/wordpress-7.2/entity-actions-api.php index 03c8fe3c9b14ba..752aa9c0cfee2a 100644 --- a/lib/compat/wordpress-7.2/entity-actions-api.php +++ b/lib/compat/wordpress-7.2/entity-actions-api.php @@ -193,3 +193,33 @@ function _wp_add_entity_action_modules_to_editor_script( $scripts = null ) { } add_action( 'admin_footer', '_wp_add_entity_action_modules_to_editor_script', 10, 0 ); } + +if ( ! function_exists( '_wp_preload_entity_actions' ) ) { + /** + * Preloads the actions of the post type the post editor edits. + * + * The actions menu of the post editor reads them from the `wp/v2/entity-actions` + * route as it loads, see `useEntityActions()` in + * packages/editor/src/dataviews/entity-actions-loader.ts. The path must match the + * request of the `getEntityActionsConfig` core data resolver, see + * packages/core-data/src/resolvers.js. + * + * @param array $paths REST API paths to preload. + * @param WP_Block_Editor_Context $context Block editor context. + * @return array Filtered preload paths. + */ + function _wp_preload_entity_actions( $paths, $context ) { + if ( 'core/edit-post' === $context->name && isset( $context->post ) ) { + $paths[] = add_query_arg( + array( + 'kind' => 'postType', + 'name' => $context->post->post_type, + ), + '/wp/v2/entity-actions' + ); + } + + return $paths; + } + add_filter( 'block_editor_rest_api_preload_paths', '_wp_preload_entity_actions', 10, 2 ); +} diff --git a/packages/edit-post/src/index.jsx b/packages/edit-post/src/index.jsx index fbc90b78209e6b..d4e72c327b7525 100644 --- a/packages/edit-post/src/index.jsx +++ b/packages/edit-post/src/index.jsx @@ -207,6 +207,16 @@ async function preloadResolutions( postType, postId ) { .catch( () => {} ), ] : [] ), + // The actions of the post type, which the actions menu of the + // document sidebar reads with `useEntityActions`. It reports a failure + // of its own, so this must not cut the rest of the kickoff short. + ...( postType + ? [ + unlock( core ) + .getEntityActionsConfig( 'postType', postType ) + .catch( () => {} ), + ] + : [] ), // Per-post resolvers. `getPostType` and `getEditedEntityRecord` // are shorthand/forward-resolver aliases with their own // resolution metadata, so they need separate kicks. diff --git a/phpunit/entity-actions-api-test.php b/phpunit/entity-actions-api-test.php index 0d7668b229d761..3dd4bbd516b87a 100644 --- a/phpunit/entity-actions-api-test.php +++ b/phpunit/entity-actions-api-test.php @@ -5,6 +5,7 @@ * @package gutenberg * * @covers ::_wp_add_entity_action_modules_to_editor_script + * @covers ::_wp_preload_entity_actions * @covers ::wp_register_entity_actions * @covers ::wp_unregister_entity_actions * @covers ::gutenberg_override_wp_entity_actions_registry @@ -369,6 +370,44 @@ public function test_action_script_modules_reach_the_import_map() { $this->assertArrayHasKey( 'plugin/feature', $import_map['imports'] ); } + /** + * The post editor preloads the actions of the post type it edits, with + * the path the `getEntityActionsConfig` resolver of core data requests. + */ + public function test_the_post_editor_preloads_the_actions_of_the_post_type() { + $context = new WP_Block_Editor_Context( array( 'post' => self::factory()->post->create_and_get( array( 'post_type' => 'page' ) ) ) ); + + $paths = apply_filters( 'block_editor_rest_api_preload_paths', array(), $context ); + + $this->assertContains( '/wp/v2/entity-actions?kind=postType&name=page', $paths ); + } + + /** + * The preloaded path serves the actions of the post type. + */ + public function test_the_preloaded_actions_path_serves_the_actions() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $context = new WP_Block_Editor_Context( array( 'post' => self::factory()->post->create_and_get( array( 'post_type' => 'page' ) ) ) ); + + $paths = _wp_preload_entity_actions( array(), $context ); + $preloaded = array_reduce( $paths, 'rest_preload_api_request', array() ); + + $this->assertSame( + array( 'rename-post', 'move-to-trash' ), + array_column( $preloaded['/wp/v2/entity-actions?kind=postType&name=page']['body']['actions'], 'id' ) + ); + } + + /** + * Only the post editor preloads the actions: the other editors have no + * post type of their own. + */ + public function test_other_editors_do_not_preload_the_actions() { + $context = new WP_Block_Editor_Context( array( 'name' => 'core/edit-site' ) ); + + $this->assertSame( array( '/wp/v2/types' ), _wp_preload_entity_actions( array( '/wp/v2/types' ), $context ) ); + } + /** * Reading the registry fires `wp_entity_actions_api_init` once, whichever * getter is read first and however many times it is read. diff --git a/test/e2e/specs/preload/site-editor.spec.js b/test/e2e/specs/preload/site-editor.spec.js index 5038a2992df03f..b1c92c49d12ba8 100644 --- a/test/e2e/specs/preload/site-editor.spec.js +++ b/test/e2e/specs/preload/site-editor.spec.js @@ -81,6 +81,7 @@ test.describe( 'Preload', () => { // To do: these should all be removed or preloaded. expect( Array.from( new Set( requests ) ).sort() ).toEqual( [ + 'GET /wp/v2/entity-actions?kind=postType&name=page', `GET /wp/v2/comments?context=edit&post=${ pageId }&type=note&status=all&per_page=100`, `GET /wp/v2/pages/${ pageId }/autosaves?context=edit`, 'GET /wp/v2/taxonomies?context=edit', From 0aac32f98885ac2a16dbb0639d35fc1296733955 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 14 Oct 2026 13:31:43 +0100 Subject: [PATCH 04/20] E2E: Add a test plugin that adds an action to pages The plugin adds an action whose eligibility and callback come from its script module. Its module also exports an action no registration names, which must not show. The spec checks the lists of the site editor and the inspector of the editor, and runs on both site editors. It moves here from https://github.com/WordPress/gutenberg/pull/84650, which keeps the server code. --- .../e2e-tests/plugins/entity-actions-api.php | 59 +++++++ .../entity-actions-api/close-comments.js | 49 ++++++ .../site-editor/entity-actions-api.spec.js | 163 ++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 packages/e2e-tests/plugins/entity-actions-api.php create mode 100644 packages/e2e-tests/plugins/entity-actions-api/close-comments.js create mode 100644 test/e2e/specs/site-editor/entity-actions-api.spec.js diff --git a/packages/e2e-tests/plugins/entity-actions-api.php b/packages/e2e-tests/plugins/entity-actions-api.php new file mode 100644 index 00000000000000..48b935de68aaff --- /dev/null +++ b/packages/e2e-tests/plugins/entity-actions-api.php @@ -0,0 +1,59 @@ + 'gutenberg-test-entity-actions-api/close-comments', + 'label' => 'Close comments', + 'supportsBulk' => true, + ), + ), + 'gutenberg-test-entity-actions-api/close-comments' + ); +} +add_action( 'wp_entity_actions_api_init', 'gutenberg_test_entity_actions_api_add_action_with_script_module' ); diff --git a/packages/e2e-tests/plugins/entity-actions-api/close-comments.js b/packages/e2e-tests/plugins/entity-actions-api/close-comments.js new file mode 100644 index 00000000000000..9aaa57eb63325f --- /dev/null +++ b/packages/e2e-tests/plugins/entity-actions-api/close-comments.js @@ -0,0 +1,49 @@ +/** + * The JavaScript parts of the `gutenberg-test-entity-actions-api/close-comments` + * action the test plugin registers for Pages. + * + * The default export maps action ids to the properties of the action that PHP + * cannot serialize. The module applies to the actions it was registered with: + * `gutenberg-test-entity-actions-api/close-comments`. The + * `gutenberg-test-entity-actions-api/archive` entry is a whole action no PHP + * registration names: the editor ignores it, as a module only augments the + * actions registered with it. + * + * No build step: the callback dispatches through the data registry it + * receives, so the module imports nothing. + */ + +const closeComments = { + isEligible: ( item ) => item.comment_status === 'open', + callback: async ( items, { registry, onActionPerformed } ) => { + const { saveEntityRecord } = registry.dispatch( 'core' ); + await Promise.all( + items.map( ( item ) => + saveEntityRecord( 'postType', 'page', { + id: item.id, + comment_status: 'closed', + } ) + ) + ); + registry + .dispatch( 'core/notices' ) + .createSuccessNotice( 'Comments closed.', { type: 'snackbar' } ); + onActionPerformed?.( items ); + }, +}; + +/** + * A complete action definition, id and label included, with no registration + * in PHP naming it. The editor merges a module into the actions the server + * registered the module with, so this entry never becomes an action. + */ +const archive = { + id: 'gutenberg-test-entity-actions-api/archive', + label: 'Archive', + callback: () => {}, +}; + +export default { + 'gutenberg-test-entity-actions-api/close-comments': closeComments, + 'gutenberg-test-entity-actions-api/archive': archive, +}; diff --git a/test/e2e/specs/site-editor/entity-actions-api.spec.js b/test/e2e/specs/site-editor/entity-actions-api.spec.js new file mode 100644 index 00000000000000..a28b269e4b8a1e --- /dev/null +++ b/test/e2e/specs/site-editor/entity-actions-api.spec.js @@ -0,0 +1,163 @@ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +const PLUGIN_SLUG = 'gutenberg-test-entity-actions-api'; + +/** + * Creates a published page. + * + * @param {Object} requestUtils The request utils. + * @param {Object} data The data of the page. + * @return {Promise} The page. + */ +function createPage( requestUtils, data ) { + return requestUtils.rest( { + method: 'POST', + path: '/wp/v2/pages', + data: { ...data, status: 'publish' }, + } ); +} + +/** + * Switches the Pages list to the table layout and opens the actions menu of + * the row of a page. + * + * @param {import('@playwright/test').Page} page The page. + * @param {string} title The title of the page. + */ +async function openRowActions( page, title ) { + await page.getByRole( 'button', { name: 'Layout', exact: true } ).click(); + await page.getByRole( 'menuitemradio', { name: 'Table' } ).click(); + await page + .getByRole( 'row', { name: new RegExp( title ) } ) + .getByRole( 'button', { name: 'Actions' } ) + .click(); + await expect( page.getByRole( 'menuitem' ).first() ).toBeVisible(); +} + +test.describe( 'Entity actions API', () => { + // Switching the theme writes the `template` and the `stylesheet` options + // one after the other, so a request that lands between the two reads a + // theme that does not exist and answers 500. Keep it on its own. + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'emptytheme' ); + await requestUtils.activatePlugin( PLUGIN_SLUG ); + } ); + + // The layout persists in the user preferences. + test.beforeEach( async ( { requestUtils } ) => { + await Promise.all( [ + requestUtils.resetPreferences(), + requestUtils.deleteAllPages(), + ] ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await Promise.all( [ + requestUtils.deactivatePlugin( PLUGIN_SLUG ), + requestUtils.resetPreferences(), + requestUtils.deleteAllPages(), + ] ); + await requestUtils.activateTheme( 'twentytwentyone' ); + } ); + + test( 'adds an action from a script module', async ( { + admin, + page, + requestUtils, + } ) => { + const [ openPage ] = await Promise.all( [ + createPage( requestUtils, { + title: 'Open Page', + comment_status: 'open', + } ), + createPage( requestUtils, { + title: 'Closed Page', + comment_status: 'closed', + } ), + ] ); + await admin.visitSiteEditor( { postType: 'page' } ); + + // The eligibility of the action comes from its script module. + await openRowActions( page, 'Closed Page' ); + await expect( + page.getByRole( 'menuitem', { name: 'Rename…' } ) + ).toBeVisible(); + await expect( + page.getByRole( 'menuitem', { name: 'Close comments' } ) + ).toHaveCount( 0 ); + await page.keyboard.press( 'Escape' ); + + // So does its callback. + await page + .getByRole( 'row', { name: /Open Page/ } ) + .getByRole( 'button', { name: 'Actions' } ) + .click(); + await page.getByRole( 'menuitem', { name: 'Close comments' } ).click(); + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Comments closed.' } ) + ).toBeVisible(); + await expect + .poll( + async () => + ( + await requestUtils.rest( { + path: `/wp/v2/pages/${ openPage.id }`, + } ) + ).comment_status + ) + .toBe( 'closed' ); + } ); + + test( 'ignores an action defined only in a script module', async ( { + admin, + page, + requestUtils, + } ) => { + await createPage( requestUtils, { + title: 'Open Page', + comment_status: 'open', + } ); + await admin.visitSiteEditor( { postType: 'page' } ); + await openRowActions( page, 'Open Page' ); + + // The module loaded: the action registered with it shows. + await expect( + page.getByRole( 'menuitem', { name: 'Close comments' } ) + ).toBeVisible(); + + // The same module exports a complete definition of an `archive` + // action no PHP registration names. A module only augments the + // actions it was registered with, so the action is not shown. + await expect( + page.getByRole( 'menuitem', { name: 'Archive' } ) + ).toHaveCount( 0 ); + } ); + + test( 'shows the actions in the editor inspector', async ( { + admin, + editor, + page, + requestUtils, + } ) => { + const openPage = await createPage( requestUtils, { + title: 'Open Page', + comment_status: 'open', + } ); + await admin.editPost( openPage.id ); + await editor.openDocumentSettingsSidebar(); + await page + .getByRole( 'region', { name: 'Editor settings' } ) + .getByRole( 'button', { name: 'Actions' } ) + .click(); + + // The core actions show along with the action registered on the server. + await expect( + page.getByRole( 'menuitem', { name: 'Duplicate…' } ) + ).toBeVisible(); + await expect( + page.getByRole( 'menuitem', { name: 'Close comments' } ) + ).toBeVisible(); + } ); +} ); From c21d4ee2001a4c6f8efda7f6b49cedd83d9c0ce4 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 13 Oct 2026 21:28:25 +0100 Subject: [PATCH 05/20] Core Actions: Register the core actions on the server Give each action an `action.php` next to its `action.tsx`, returning the serializable part of the action, with its labels translated on the server. `index.php` registers the actions on `wp_entity_actions_api_init`, at priority 0 so plugins can remove or replace them, for every post type exposed in the REST API, under the conditions `registerPostTypeSchema()` uses and in the order the editor shows them, each with the script module of its folder. The build copies the PHP files with the `gutenberg_` prefix, and the plugin loads `index.php` from the build. --- .../wordpress-7.2/entity-actions-api.php | 3 +- lib/load.php | 3 + packages/core-actions/package.json | 10 + packages/core-actions/src/index.php | 88 ++++ .../post_type_supports/delete-post/action.php | 17 + .../duplicate-post/action.php | 15 + .../move-to-trash/action.php | 16 + .../post_type_supports/order-pages/action.php | 14 + .../permanently-delete/action.php | 16 + .../post_type_supports/rename-post/action.php | 14 + .../post_type_supports/reset-post/action.php | 15 + .../src/post_type_supports/restore/action.php | 14 + .../view-post-revisions/action.php | 14 + .../post_type_supports/view-post/action.php | 13 + .../src/wp_block/duplicate-pattern/action.php | 14 + .../src/wp_block/export-pattern/action.php | 12 + .../duplicate-template-part/action.php | 14 + phpunit/core-actions-test.php | 383 ++++++++++++++++++ phpunit/entity-actions-api-test.php | 14 +- 19 files changed, 683 insertions(+), 6 deletions(-) create mode 100644 packages/core-actions/src/index.php create mode 100644 packages/core-actions/src/post_type_supports/delete-post/action.php create mode 100644 packages/core-actions/src/post_type_supports/duplicate-post/action.php create mode 100644 packages/core-actions/src/post_type_supports/move-to-trash/action.php create mode 100644 packages/core-actions/src/post_type_supports/order-pages/action.php create mode 100644 packages/core-actions/src/post_type_supports/permanently-delete/action.php create mode 100644 packages/core-actions/src/post_type_supports/rename-post/action.php create mode 100644 packages/core-actions/src/post_type_supports/reset-post/action.php create mode 100644 packages/core-actions/src/post_type_supports/restore/action.php create mode 100644 packages/core-actions/src/post_type_supports/view-post-revisions/action.php create mode 100644 packages/core-actions/src/post_type_supports/view-post/action.php create mode 100644 packages/core-actions/src/wp_block/duplicate-pattern/action.php create mode 100644 packages/core-actions/src/wp_block/export-pattern/action.php create mode 100644 packages/core-actions/src/wp_template_part/duplicate-template-part/action.php create mode 100644 phpunit/core-actions-test.php diff --git a/lib/compat/wordpress-7.2/entity-actions-api.php b/lib/compat/wordpress-7.2/entity-actions-api.php index 752aa9c0cfee2a..772309be0248a7 100644 --- a/lib/compat/wordpress-7.2/entity-actions-api.php +++ b/lib/compat/wordpress-7.2/entity-actions-api.php @@ -15,7 +15,8 @@ * * Actions are registered on the `wp_entity_actions_api_init` action, and only there, * with {@see wp_register_entity_actions()} and {@see wp_unregister_entity_actions()}. The - * other functions read the registry. + * other functions read the registry. WordPress core registers its own + * actions the same way, from packages/core-actions. * * The functions are defined when core does not ship them. When it does, the * core functions read the plugin registry, which replaces the core one on diff --git a/lib/load.php b/lib/load.php index e73bc55a0e3dcb..5aeb368df5456c 100644 --- a/lib/load.php +++ b/lib/load.php @@ -226,6 +226,9 @@ function gutenberg_get_experiment_default( $name ) { } require_once __DIR__ . '/../build/scripts/core-fields/index.php'; } +if ( is_dir( __DIR__ . '/../build/scripts/core-actions' ) ) { + require_once __DIR__ . '/../build/scripts/core-actions/index.php'; +} // Block supports overrides. require __DIR__ . '/block-supports/settings.php'; diff --git a/packages/core-actions/package.json b/packages/core-actions/package.json index 37d38749f0d5ad..e5d0d3bca7118e 100644 --- a/packages/core-actions/package.json +++ b/packages/core-actions/package.json @@ -49,6 +49,16 @@ "./wp_block": "./build-module/wp_block/index.mjs", "./wp_template_part": "./build-module/wp_template_part/index.mjs" }, + "wpCopyFiles": { + "files": [ + "src/**/*.php" + ], + "transforms": { + "php": { + "functionPrefix": "gutenberg_" + } + } + }, "types": "build-types", "sideEffects": false, "dependencies": { diff --git a/packages/core-actions/src/index.php b/packages/core-actions/src/index.php new file mode 100644 index 00000000000000..2d9355178d7db0 --- /dev/null +++ b/packages/core-actions/src/index.php @@ -0,0 +1,88 @@ + true ), 'objects' ) as $post_type ) { + $name = $post_type->name; + $can_create = current_user_can( $post_type->cap->create_posts ); + + // The actions of the post type, keyed by the folder of their script + // module and their id, in the order they are shown. + $applies = array( + 'post_type_supports/view-post' => is_post_type_viewable( $post_type ), + 'post_type_supports/view-post-revisions' => post_type_supports( $name, 'revisions' ), + 'post_type_supports/duplicate-post' => $is_gutenberg && $can_create && ! in_array( $name, $design_types, true ), + 'wp_template_part/duplicate-template-part' => 'wp_template_part' === $name && $can_create && $is_block_theme, + 'wp_block/duplicate-pattern' => 'wp_block' === $name && $can_create, + 'post_type_supports/rename-post' => post_type_supports( $name, 'title' ), + 'post_type_supports/order-pages' => post_type_supports( $name, 'page-attributes' ), + 'wp_block/export-pattern' => 'wp_block' === $name, + 'post_type_supports/restore' => true, + 'post_type_supports/reset-post' => true, + 'post_type_supports/delete-post' => true, + 'post_type_supports/move-to-trash' => true, + 'post_type_supports/permanently-delete' => true, + ); + + foreach ( array_keys( array_filter( $applies ) ) as $key ) { + list( $module, $id ) = explode( '/', $key ); + if ( ! isset( $definitions[ $key ] ) ) { + // The id comes first, like in the actions registered directly. + $definitions[ $key ] = array_merge( array( 'id' => $id ), $require( __DIR__ . "/{$key}/action.php" ) ); + } + wp_register_entity_actions( 'core', 'postType', $name, array( $definitions[ $key ] ), "@wordpress/core-actions/{$module}" ); + } + } +} +add_action( 'wp_entity_actions_api_init', 'register_core_entity_actions', 0 ); diff --git a/packages/core-actions/src/post_type_supports/delete-post/action.php b/packages/core-actions/src/post_type_supports/delete-post/action.php new file mode 100644 index 00000000000000..ade23909fde232 --- /dev/null +++ b/packages/core-actions/src/post_type_supports/delete-post/action.php @@ -0,0 +1,17 @@ + __( 'Delete…', 'gutenberg' ), + 'isPrimary' => true, + 'supportsBulk' => true, + 'hideModalHeader' => true, + 'modalFocusOnMount' => 'firstContentElement', +); diff --git a/packages/core-actions/src/post_type_supports/duplicate-post/action.php b/packages/core-actions/src/post_type_supports/duplicate-post/action.php new file mode 100644 index 00000000000000..3ad6b8bc328bdc --- /dev/null +++ b/packages/core-actions/src/post_type_supports/duplicate-post/action.php @@ -0,0 +1,15 @@ + _x( 'Duplicate…', 'action label', 'gutenberg' ), + 'modalHeader' => _x( 'Duplicate', 'action label', 'gutenberg' ), + 'modalFocusOnMount' => 'firstContentElement', +); diff --git a/packages/core-actions/src/post_type_supports/move-to-trash/action.php b/packages/core-actions/src/post_type_supports/move-to-trash/action.php new file mode 100644 index 00000000000000..a6cfa8036e6242 --- /dev/null +++ b/packages/core-actions/src/post_type_supports/move-to-trash/action.php @@ -0,0 +1,16 @@ + _x( 'Trash…', 'verb', 'gutenberg' ), + 'isPrimary' => true, + 'supportsBulk' => true, + 'hideModalHeader' => true, + 'modalFocusOnMount' => 'firstContentElement', +); diff --git a/packages/core-actions/src/post_type_supports/order-pages/action.php b/packages/core-actions/src/post_type_supports/order-pages/action.php new file mode 100644 index 00000000000000..1274bbc1eb1b73 --- /dev/null +++ b/packages/core-actions/src/post_type_supports/order-pages/action.php @@ -0,0 +1,14 @@ + __( 'Order…', 'gutenberg' ), + 'modalHeader' => __( 'Order', 'gutenberg' ), + 'modalFocusOnMount' => 'firstContentElement', +); diff --git a/packages/core-actions/src/post_type_supports/permanently-delete/action.php b/packages/core-actions/src/post_type_supports/permanently-delete/action.php new file mode 100644 index 00000000000000..08cab428fa6dfa --- /dev/null +++ b/packages/core-actions/src/post_type_supports/permanently-delete/action.php @@ -0,0 +1,16 @@ + __( 'Permanently delete…', 'gutenberg' ), + 'supportsBulk' => true, + 'hideModalHeader' => true, + 'modalFocusOnMount' => 'firstContentElement', +); diff --git a/packages/core-actions/src/post_type_supports/rename-post/action.php b/packages/core-actions/src/post_type_supports/rename-post/action.php new file mode 100644 index 00000000000000..59d72c4dd2de89 --- /dev/null +++ b/packages/core-actions/src/post_type_supports/rename-post/action.php @@ -0,0 +1,14 @@ + __( 'Rename…', 'gutenberg' ), + 'modalHeader' => __( 'Rename', 'gutenberg' ), + 'modalFocusOnMount' => 'firstContentElement', +); diff --git a/packages/core-actions/src/post_type_supports/reset-post/action.php b/packages/core-actions/src/post_type_supports/reset-post/action.php new file mode 100644 index 00000000000000..2a35babbd2d038 --- /dev/null +++ b/packages/core-actions/src/post_type_supports/reset-post/action.php @@ -0,0 +1,15 @@ + __( 'Reset…', 'gutenberg' ), + 'supportsBulk' => true, + 'hideModalHeader' => true, + 'modalFocusOnMount' => 'firstContentElement', +); diff --git a/packages/core-actions/src/post_type_supports/restore/action.php b/packages/core-actions/src/post_type_supports/restore/action.php new file mode 100644 index 00000000000000..2d0fb0c5552fe2 --- /dev/null +++ b/packages/core-actions/src/post_type_supports/restore/action.php @@ -0,0 +1,14 @@ + __( 'Restore', 'gutenberg' ), + 'isPrimary' => true, + 'supportsBulk' => true, +); diff --git a/packages/core-actions/src/post_type_supports/view-post-revisions/action.php b/packages/core-actions/src/post_type_supports/view-post-revisions/action.php new file mode 100644 index 00000000000000..877499c0954097 --- /dev/null +++ b/packages/core-actions/src/post_type_supports/view-post-revisions/action.php @@ -0,0 +1,14 @@ + __( 'View revisions', 'gutenberg' ), + 'context' => 'list', +); diff --git a/packages/core-actions/src/post_type_supports/view-post/action.php b/packages/core-actions/src/post_type_supports/view-post/action.php new file mode 100644 index 00000000000000..9e21995ee10b4f --- /dev/null +++ b/packages/core-actions/src/post_type_supports/view-post/action.php @@ -0,0 +1,13 @@ + _x( 'View', 'verb', 'gutenberg' ), + 'isPrimary' => true, +); diff --git a/packages/core-actions/src/wp_block/duplicate-pattern/action.php b/packages/core-actions/src/wp_block/duplicate-pattern/action.php new file mode 100644 index 00000000000000..196b89602364f5 --- /dev/null +++ b/packages/core-actions/src/wp_block/duplicate-pattern/action.php @@ -0,0 +1,14 @@ + _x( 'Duplicate…', 'action label', 'gutenberg' ), + 'modalHeader' => _x( 'Duplicate pattern', 'action label', 'gutenberg' ), + 'modalFocusOnMount' => 'firstContentElement', +); diff --git a/packages/core-actions/src/wp_block/export-pattern/action.php b/packages/core-actions/src/wp_block/export-pattern/action.php new file mode 100644 index 00000000000000..62016a9c2ad0c2 --- /dev/null +++ b/packages/core-actions/src/wp_block/export-pattern/action.php @@ -0,0 +1,12 @@ + __( 'Export as JSON', 'gutenberg' ), + 'supportsBulk' => true, +); diff --git a/packages/core-actions/src/wp_template_part/duplicate-template-part/action.php b/packages/core-actions/src/wp_template_part/duplicate-template-part/action.php new file mode 100644 index 00000000000000..ae9f84c699eb3d --- /dev/null +++ b/packages/core-actions/src/wp_template_part/duplicate-template-part/action.php @@ -0,0 +1,14 @@ + _x( 'Duplicate…', 'action label', 'gutenberg' ), + 'modalHeader' => _x( 'Duplicate template part', 'action label', 'gutenberg' ), + 'modalFocusOnMount' => 'firstContentElement', +); diff --git a/phpunit/core-actions-test.php b/phpunit/core-actions-test.php new file mode 100644 index 00000000000000..6bc96a9c1f8ea3 --- /dev/null +++ b/phpunit/core-actions-test.php @@ -0,0 +1,383 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$editor_id = $factory->user->create( array( 'role' => 'editor' ) ); + self::$contributor_id = $factory->user->create( array( 'role' => 'contributor' ) ); + } + + /** + * Deletes shared users. + */ + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$editor_id ); + self::delete_user( self::$contributor_id ); + } + + /** + * Tears down each test. + * + * Resetting the registry drops the actions registered for the user and + * theme of the test; the next read fires `wp_entity_actions_api_init` again. + * The test case removes the callbacks the test hooked, but only resets + * the post types when running the tests of WordPress itself. + */ + public function tear_down() { + foreach ( $this->post_types as $post_type ) { + unregister_post_type( $post_type ); + } + $this->post_types = array(); + self::reset_registry(); + + parent::tear_down(); + } + + /** + * Resets the registry: drops the singleton instance and puts an empty + * plugin registry in its place, as `init` does, so its first read fires + * `wp_entity_actions_api_init` again. + */ + private static function reset_registry() { + $instance = new ReflectionProperty( WP_Entity_Actions_Registry::class, 'instance' ); + if ( PHP_VERSION_ID < 80100 ) { + $instance->setAccessible( true ); + } + $instance->setValue( null, null ); + WP_Entity_Actions_Registry_Gutenberg::get_instance(); + } + + /** + * Returns the ids of the actions registered for a post type, from a + * registry built anew for the current user and theme, as each request + * builds it. + * + * @param string $post_type The post type. + * @return string[] The ids of the actions, in registration order. + */ + private function get_action_ids( $post_type ) { + self::reset_registry(); + return array_column( wp_get_registered_entity_actions( 'postType', $post_type ), 'id' ); + } + + /** + * Registers a post type for the duration of the test. + * + * @param string $post_type The post type. + * @param array $args The arguments of the post type. + */ + private function register_post_type( $post_type, $args ) { + register_post_type( $post_type, $args ); + $this->post_types[] = $post_type; + } + + /** + * The core actions are registered at priority 0, so a plugin hooking the + * action at the default priority sees them. + */ + public function test_the_core_actions_are_registered_at_priority_0() { + $this->assertSame( 0, has_action( 'wp_entity_actions_api_init', 'gutenberg_register_core_entity_actions' ) ); + } + + /** + * Pages are viewable and support revisions, titles, and page attributes, + * and the user can create them: they have every action of the + * `post_type_supports` module, in the order the actions are shown. + */ + public function test_the_actions_of_pages() { + wp_set_current_user( self::$admin_id ); + + $ids = array_merge( array( 'view-post', 'view-post-revisions', 'duplicate-post', 'rename-post', 'order-pages' ), self::EVERY_POST_TYPE ); + $this->assertSame( $ids, $this->get_action_ids( 'page' ) ); + $this->assertSame( array( self::POST_TYPE_SUPPORTS => $ids ), wp_get_registered_entity_action_modules( 'postType', 'page' ) ); + $this->assertSame( array( 'core' ), array_unique( array_column( array_column( wp_get_registered_entity_actions( 'postType', 'page' ), 'origin' ), 'registeredBy' ) ), 'Every action is registered by core.' ); + } + + /** + * Posts do not support page attributes, so they cannot be ordered. + */ + public function test_the_actions_of_posts() { + wp_set_current_user( self::$admin_id ); + + $this->assertSame( + array_merge( array( 'view-post', 'view-post-revisions', 'duplicate-post', 'rename-post' ), self::EVERY_POST_TYPE ), + $this->get_action_ids( 'post' ) + ); + } + + /** + * Patterns are not viewable and are duplicated and exported by the + * actions of the `wp_block` module, placed among the others in the order + * the actions are shown. + */ + public function test_the_actions_of_patterns() { + wp_set_current_user( self::$admin_id ); + + $this->assertSame( + array_merge( array( 'view-post-revisions', 'duplicate-pattern', 'rename-post', 'export-pattern' ), self::EVERY_POST_TYPE ), + $this->get_action_ids( 'wp_block' ) + ); + $this->assertSame( + array( + self::POST_TYPE_SUPPORTS => array_merge( array( 'view-post-revisions', 'rename-post' ), self::EVERY_POST_TYPE ), + self::WP_BLOCK => array( 'duplicate-pattern', 'export-pattern' ), + ), + wp_get_registered_entity_action_modules( 'postType', 'wp_block' ) + ); + } + + /** + * Templates are not duplicated: the editor creates them from the theme. + */ + public function test_the_actions_of_templates() { + wp_set_current_user( self::$admin_id ); + switch_theme( 'block-theme' ); + + $this->assertSame( + array_merge( array( 'view-post-revisions', 'rename-post' ), self::EVERY_POST_TYPE ), + $this->get_action_ids( 'wp_template' ) + ); + } + + /** + * Template parts are duplicated by the action of the `wp_template_part` + * module, with a block theme only. + */ + public function test_template_parts_are_only_duplicated_with_a_block_theme() { + wp_set_current_user( self::$admin_id ); + + switch_theme( 'block-theme' ); + $this->assertSame( + array_merge( array( 'view-post-revisions', 'duplicate-template-part', 'rename-post' ), self::EVERY_POST_TYPE ), + $this->get_action_ids( 'wp_template_part' ), + 'A block theme can duplicate template parts.' + ); + $this->assertSame( array( 'duplicate-template-part' ), wp_get_registered_entity_action_modules( 'postType', 'wp_template_part' )[ self::WP_TEMPLATE_PART ] ); + + switch_theme( 'default' ); + $this->assertNotContains( 'duplicate-template-part', $this->get_action_ids( 'wp_template_part' ), 'A classic theme cannot.' ); + } + + /** + * The duplicate actions are only registered for the users who can create + * the posts they duplicate into: the registry is built for the current + * user. + */ + public function test_the_duplicate_actions_depend_on_the_current_user() { + switch_theme( 'block-theme' ); + + wp_set_current_user( self::$admin_id ); + $this->assertContains( 'duplicate-post', $this->get_action_ids( 'page' ) ); + $this->assertContains( 'duplicate-pattern', $this->get_action_ids( 'wp_block' ) ); + $this->assertContains( 'duplicate-template-part', $this->get_action_ids( 'wp_template_part' ) ); + + wp_set_current_user( self::$editor_id ); + $this->assertContains( 'duplicate-post', $this->get_action_ids( 'page' ), 'Editors can create pages.' ); + $this->assertContains( 'duplicate-pattern', $this->get_action_ids( 'wp_block' ), 'Editors can create patterns.' ); + $this->assertNotContains( 'duplicate-template-part', $this->get_action_ids( 'wp_template_part' ), 'Editors cannot create template parts.' ); + + wp_set_current_user( self::$contributor_id ); + $this->assertNotContains( 'duplicate-post', $this->get_action_ids( 'page' ), 'Contributors cannot create pages.' ); + $this->assertContains( 'duplicate-post', $this->get_action_ids( 'post' ), 'Contributors can create posts.' ); + $this->assertNotContains( 'duplicate-pattern', $this->get_action_ids( 'wp_block' ), 'Contributors cannot create patterns.' ); + $this->assertContains( 'export-pattern', $this->get_action_ids( 'wp_block' ), 'Contributors can export them.' ); + } + + /** + * The actions of a custom post type follow what it supports. + */ + public function test_the_actions_of_a_custom_post_type_follow_its_supports() { + $this->register_post_type( + 'gutenberg_book', + array( + 'public' => true, + 'show_in_rest' => true, + 'supports' => array( 'title', 'revisions' ), + ) + ); + $this->register_post_type( + 'gutenberg_note', + array( + 'show_ui' => true, + 'show_in_rest' => true, + 'supports' => array( 'editor' ), + ) + ); + $this->register_post_type( + 'gutenberg_hidden', + array( + 'public' => true, + 'show_in_rest' => false, + ) + ); + wp_set_current_user( self::$admin_id ); + + $this->assertSame( + array_merge( array( 'view-post', 'view-post-revisions', 'duplicate-post', 'rename-post' ), self::EVERY_POST_TYPE ), + $this->get_action_ids( 'gutenberg_book' ), + 'A viewable post type with titles and revisions.' + ); + $this->assertSame( + array_merge( array( 'duplicate-post' ), self::EVERY_POST_TYPE ), + $this->get_action_ids( 'gutenberg_note' ), + 'A post type that is not viewable, without titles or revisions.' + ); + $this->assertSame( array(), $this->get_action_ids( 'gutenberg_hidden' ), 'A post type not exposed in the REST API has no action.' ); + } + + /** + * The definitions are the ones of the `action.php` files, with the id + * first. + */ + public function test_the_definitions_come_from_the_action_files() { + wp_set_current_user( self::$admin_id ); + self::reset_registry(); + + $actions = array_column( wp_get_registered_entity_actions( 'postType', 'page' ), null, 'id' ); + $this->assertSame( + array( + 'id' => 'move-to-trash', + 'label' => 'Trash…', + 'isPrimary' => true, + 'supportsBulk' => true, + 'hideModalHeader' => true, + 'modalFocusOnMount' => 'firstContentElement', + 'origin' => array( 'registeredBy' => 'core' ), + ), + $actions['move-to-trash'] + ); + $this->assertSame( + array( + 'id' => 'view-post-revisions', + 'label' => 'View revisions', + 'context' => 'list', + 'origin' => array( 'registeredBy' => 'core' ), + ), + $actions['view-post-revisions'] + ); + } + + /** + * The REST route serves the core actions of each post type, with their + * script modules, in a response that validates against its schema. + */ + public function test_the_rest_route_serves_the_core_actions() { + wp_set_current_user( self::$admin_id ); + switch_theme( 'block-theme' ); + $schema = ( new Gutenberg_REST_Entity_Actions_Controller_7_2() )->get_item_schema(); + + $modules = array( + 'page' => array( self::POST_TYPE_SUPPORTS ), + 'wp_block' => array( self::POST_TYPE_SUPPORTS, self::WP_BLOCK ), + 'wp_template_part' => array( self::POST_TYPE_SUPPORTS, self::WP_TEMPLATE_PART ), + ); + foreach ( $modules as $post_type => $module_ids ) { + self::reset_registry(); + $request = new WP_REST_Request( 'GET', '/wp/v2/entity-actions' ); + $request->set_query_params( + array( + 'kind' => 'postType', + 'name' => $post_type, + ) + ); + $data = rest_get_server()->dispatch( $request )->get_data(); + + $this->assertSame( array_column( wp_get_registered_entity_actions( 'postType', $post_type ), 'id' ), array_column( $data['actions'], 'id' ), "The actions of $post_type are served." ); + $this->assertSame( $module_ids, array_column( $data['script_modules'], 'id' ), 'So are their script modules.' ); + $this->assertTrue( rest_validate_value_from_schema( $data, $schema ), "The response for $post_type matches the schema." ); + } + } + + /** + * A plugin hooking the action at the default priority can remove a core + * action, and replace one: it unregisters the action and registers its + * own definition with the script module of the core action, which keeps + * its JavaScript parts. The replaced action moves after the others. + */ + public function test_a_plugin_can_remove_and_replace_core_actions() { + $callback = static function () { + wp_unregister_entity_actions( 'postType', 'page', array( 'duplicate-post' ) ); + $trash = wp_unregister_entity_actions( 'postType', 'page', array( 'move-to-trash' ) ); + wp_register_entity_actions( + 'test-plugin', + 'postType', + 'page', + array( array_merge( $trash[0], array( 'label' => 'Bin…' ) ) ), + '@wordpress/core-actions/post_type_supports' + ); + }; + add_action( 'wp_entity_actions_api_init', $callback ); + wp_set_current_user( self::$admin_id ); + + $ids = array( 'view-post', 'view-post-revisions', 'rename-post', 'order-pages', 'restore', 'reset-post', 'delete-post', 'permanently-delete', 'move-to-trash' ); + $this->assertSame( $ids, $this->get_action_ids( 'page' ) ); + $this->assertSame( array( self::POST_TYPE_SUPPORTS => $ids ), wp_get_registered_entity_action_modules( 'postType', 'page' ) ); + + $actions = array_column( wp_get_registered_entity_actions( 'postType', 'page' ), null, 'id' ); + $this->assertSame( 'Bin…', $actions['move-to-trash']['label'] ); + $this->assertSame( 'test-plugin', $actions['move-to-trash']['origin']['registeredBy'] ); + $this->assertTrue( $actions['move-to-trash']['isPrimary'], 'The rest of the core definition is kept.' ); + } +} diff --git a/phpunit/entity-actions-api-test.php b/phpunit/entity-actions-api-test.php index 3dd4bbd516b87a..0493d57a8e3681 100644 --- a/phpunit/entity-actions-api-test.php +++ b/phpunit/entity-actions-api-test.php @@ -17,12 +17,15 @@ class Tests_Entity_Actions_API extends WP_UnitTestCase { /** - * Sets up each test: the default actions of the tests are registered at - * priority 0, so every test runs against a registry holding actions - * registered before the callbacks hooked at the default priority run. + * Sets up each test: the actions WordPress registers are replaced with + * the default actions of the tests, hooked at priority 0 as WordPress + * registers its own, so every test runs against a registry holding + * actions registered before the callbacks hooked at the default priority + * run. */ public function set_up() { parent::set_up(); + remove_action( 'wp_entity_actions_api_init', 'gutenberg_register_core_entity_actions', 0 ); add_action( 'wp_entity_actions_api_init', array( __CLASS__, 'register_default_actions' ), 0 ); } @@ -31,8 +34,9 @@ public function set_up() { * * Resetting the registry drops the actions a test registered along with * the defaults; the next read fires `wp_entity_actions_api_init` again and - * registers the defaults anew. The test case removes the callbacks the - * test hooked, the defaults included. + * registers the defaults anew. The test case restores the hooks: it + * removes the callbacks the test hooked, the defaults included, and hooks + * the actions WordPress registers back. */ public function tear_down() { self::reset_registry(); From e6e93e74e66f72eff6cff9f0bdf78cec778a57d8 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 14 Oct 2026 01:58:05 +0100 Subject: [PATCH 06/20] Editor: Read the core actions from the server The core actions are registered on the server too, so `usePostActions()` shows only the actions `useEntityActions()` reads from there. `registerPostTypeSchema()` no longer registers actions, the `core/editor` store no longer holds them, and the editor no longer depends on `@wordpress/core-actions`. `registerEntityAction()` and `unregisterEntityAction()` are deprecated and have no effect: actions are registered in PHP on the `wp_entity_actions_api_init` action. --- docs/contributors/code/deprecations.md | 4 + docs/private-apis.md | 3 - package-lock.json | 1 - packages/editor/CHANGELOG.md | 4 + packages/editor/README.md | 12 +- packages/editor/package.json | 1 - .../src/components/post-actions/actions.jsx | 33 +-- .../post-actions/test/index.browser.test.tsx | 8 +- .../src/components/post-fields/index.ts | 4 +- .../post-fields/test/index.jsdom.test.tsx | 4 +- packages/editor/src/dataviews/api.js | 59 +++-- .../src/dataviews/store/private-actions.ts | 176 +-------------- .../src/dataviews/store/private-selectors.ts | 4 - .../editor/src/dataviews/store/reducer.ts | 40 +--- .../store/test/private-actions.jsdom.test.ts | 202 ------------------ .../editor/src/store/private-selectors.js | 5 - packages/editor/tsconfig.build.json | 1 - packages/editor/tsconfig.json | 1 - test/unit/vitest.config.mjs | 7 - 19 files changed, 65 insertions(+), 504 deletions(-) delete mode 100644 packages/editor/src/dataviews/store/test/private-actions.jsdom.test.ts diff --git a/docs/contributors/code/deprecations.md b/docs/contributors/code/deprecations.md index 5f5c71e5132b59..7314b12d2704da 100644 --- a/docs/contributors/code/deprecations.md +++ b/docs/contributors/code/deprecations.md @@ -7,6 +7,10 @@ For features included in the Gutenberg plugin, the deprecation policy is intende - `wp.editor.registerEntityField` and `wp.editor.unregisterEntityField` are deprecated since Gutenberg 24.3 and have no effect: the fields of an entity are registered on the server. Register and unregister fields in PHP instead, with `wp_register_entity_fields()` and `wp_unregister_entity_fields()` on the `wp_entity_fields_api_init` action, and a script module for the parts PHP cannot serialize (`render`, `Edit`, `getElements`…). Both functions remain exported, so existing calls keep working and only log a deprecation warning. - `wp.blocks.isValidBlockContent` has been removed. Please use `wp.blocks.validateBlock` instead. +## 24.5 + +- `wp.editor.registerEntityAction` and `wp.editor.unregisterEntityAction`, deprecated since 24.3 and available only in the Gutenberg plugin, have no effect: every action of an entity is registered on the server. Register and unregister actions in PHP on the `wp_entity_actions_api_init` action instead, with a script module for the parts PHP cannot serialize (`callback`, `RenderModal`, `isEligible`…). + ## 11.0.0 - `wp.blocks.registerBlockTypeFromMetadata` method has been removed. Use `wp.blocks.registerBlockType` method instead. diff --git a/docs/private-apis.md b/docs/private-apis.md index 584c88b11dc800..417689acfc2bac 100644 --- a/docs/private-apis.md +++ b/docs/private-apis.md @@ -291,7 +291,6 @@ Private exports: Private actions: - `createTemplate` - `hideBlockTypes` -- `registerEntityAction` - `registerPostTypeActions` - `removeTemplates` - `revertTemplate` @@ -299,10 +298,8 @@ Private actions: - `setCurrentTemplateId` - `setIsReady` - `showBlockTypes` -- `unregisterEntityAction` Private selectors: -- `getEntityActions` - `getInserter` - `getInserterSidebarToggleRef` - `getListViewToggleRef` diff --git a/package-lock.json b/package-lock.json index 57e6302198781e..92007866881b26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46352,7 +46352,6 @@ "@wordpress/commands": "file:../commands", "@wordpress/components": "file:../components", "@wordpress/compose": "file:../compose", - "@wordpress/core-actions": "file:../core-actions", "@wordpress/core-data": "file:../core-data", "@wordpress/data": "file:../data", "@wordpress/dataviews": "file:../dataviews", diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 896f6ab9797a21..c19e660db44e87 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -6,6 +6,8 @@ - Raise the minimum required Node.js version to `>=22.13.0`, for stable `require(esm)` support ([#84313](https://github.com/WordPress/gutenberg/pull/84313)). - The editor stylesheet no longer includes the styles of `@wordpress/fields`. A plugin that renders `MediaEdit` loads `@wordpress/fields/build-style/style.css` with its own script, as the package README describes ([#84897](https://github.com/WordPress/gutenberg/pull/84897)). +- Every action of a post type now comes from the server: `usePostActions` shows the actions the `wp/v2/entity-actions` route returns, merged with the JavaScript parts of the script modules registered along with them, and `registerPostTypeSchema()` registers no action. +- `registerEntityAction` and `unregisterEntityAction`, only available in the Gutenberg plugin, have no effect. They still warn, and they no longer register or unregister anything: every action of an entity is registered on the server. Register and unregister actions in PHP on the `wp_entity_actions_api_init` action instead, with a script module for their JavaScript parts. ### New Features @@ -20,6 +22,7 @@ ### Deprecations - `registerEntityField` and `unregisterEntityField`, which only had an effect in the Gutenberg plugin, are deprecated and have no effect anymore: the fields of an entity are registered on the server. Register and unregister them in PHP, with `wp_register_entity_fields()` and `wp_unregister_entity_fields()` on the `wp_entity_fields_api_init` action, and a script module for their JavaScript parts ([#84700](https://github.com/WordPress/gutenberg/pull/84700)). +- `registerEntityAction` and `unregisterEntityAction`, only available in the Gutenberg plugin, are deprecated. Register and unregister actions in PHP on the `wp_entity_actions_api_init` action instead, with a script module for their JavaScript parts. ### Bug Fixes @@ -37,6 +40,7 @@ - Move footer-specific block breadcrumb layout styles into the package ([#84295](https://github.com/WordPress/gutenberg/pull/84295)). - Import the core actions `registerPostTypeSchema()` registers from the `@wordpress/core-actions` script modules instead of `@wordpress/fields` ([#84643](https://github.com/WordPress/gutenberg/pull/84643)). - Take `CreateTemplatePartModal` from the private APIs of `@wordpress/patterns` instead of `@wordpress/fields`, and remove it from the private APIs of the editor ([#84643](https://github.com/WordPress/gutenberg/pull/84643)). +- The `core/editor` store no longer holds the actions of an entity: its private `registerEntityAction`, `unregisterEntityAction` and `getEntityActions` are gone. ## 15.2.0 (2026-10-07) diff --git a/packages/editor/README.md b/packages/editor/README.md index bba70c6cef4741..2a1bdcd7b4fa93 100644 --- a/packages/editor/README.md +++ b/packages/editor/README.md @@ -1548,9 +1548,11 @@ Undocumented declaration. ### registerEntityAction -Registers a new DataViews action. +> **Deprecated** since Gutenberg 24.3. Register the action in PHP on the `wp_entity_actions_api_init` action instead, with a script module for its JavaScript parts. -This is an experimental API and is subject to change. it's only available in the Gutenberg plugin for now. +Has no effect: every action of an entity is registered on the server. + +Register the action in PHP on the `wp_entity_actions_api_init` action instead, with a script module for the parts PHP cannot serialize (`callback`, `RenderModal`, `isEligible`…). _Parameters_ @@ -1657,9 +1659,11 @@ Undocumented declaration. ### unregisterEntityAction -Unregisters a DataViews action. +> **Deprecated** since Gutenberg 24.3. Unregister the action in PHP on the `wp_entity_actions_api_init` action instead. + +Has no effect: every action of an entity is registered on the server. -This is an experimental API and is subject to change. it's only available in the Gutenberg plugin for now. +Unregister the action in PHP on the `wp_entity_actions_api_init` action instead. _Parameters_ diff --git a/packages/editor/package.json b/packages/editor/package.json index 9c1260280fe1b2..9284dc094c500a 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -67,7 +67,6 @@ "@wordpress/commands": "file:../commands", "@wordpress/components": "file:../components", "@wordpress/compose": "file:../compose", - "@wordpress/core-actions": "file:../core-actions", "@wordpress/core-data": "file:../core-data", "@wordpress/data": "file:../data", "@wordpress/dataviews": "file:../dataviews", diff --git a/packages/editor/src/components/post-actions/actions.jsx b/packages/editor/src/components/post-actions/actions.jsx index b8950807520b1e..06e10134a5a444 100644 --- a/packages/editor/src/components/post-actions/actions.jsx +++ b/packages/editor/src/components/post-actions/actions.jsx @@ -1,23 +1,12 @@ -import { useDispatch, useSelect } from '@wordpress/data'; -import { useMemo, useEffect } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; import { store as coreStore } from '@wordpress/core-data'; -import { store as editorStore } from '../../store'; -import { unlock } from '../../lock-unlock'; import { useEntityActions } from '../../dataviews/entity-actions-loader'; import { useSetAsHomepageAction } from './set-as-homepage'; import { useSetAsPostsPageAction } from './set-as-posts-page'; export function usePostActions( { postType, onActionPerformed, context } ) { - const { defaultActions } = useSelect( - ( select ) => { - const { getEntityActions } = unlock( select( editorStore ) ); - return { - defaultActions: getEntityActions( 'postType', postType ), - }; - }, - [ postType ] - ); - const serverActions = useEntityActions( { + const defaultActions = useEntityActions( { kind: 'postType', name: postType, } ); @@ -72,21 +61,8 @@ export function usePostActions( { postType, onActionPerformed, context } ) { const setAsHomepageAction = useSetAsHomepageAction(); const setAsPostsPageAction = useSetAsPostsPageAction(); - const { registerPostTypeSchema } = unlock( useDispatch( editorStore ) ); - useEffect( () => { - registerPostTypeSchema( postType ); - }, [ registerPostTypeSchema, postType ] ); - return useMemo( () => { - // The actions registered on the server follow the ones of the store, - // and cannot take their ids. - let actions = [ - ...defaultActions, - ...serverActions.filter( - ( action ) => - ! defaultActions.some( ( { id } ) => id === action.id ) - ), - ]; + let actions = [ ...defaultActions ]; if ( shouldShowHomepageActions ) { actions.push( setAsHomepageAction, setAsPostsPageAction ); } @@ -161,7 +137,6 @@ export function usePostActions( { postType, onActionPerformed, context } ) { context, defaultActions, onActionPerformed, - serverActions, setAsHomepageAction, setAsPostsPageAction, shouldShowHomepageActions, diff --git a/packages/editor/src/components/post-actions/test/index.browser.test.tsx b/packages/editor/src/components/post-actions/test/index.browser.test.tsx index 7c830c0d46f42f..0b26adec42e0f6 100644 --- a/packages/editor/src/components/post-actions/test/index.browser.test.tsx +++ b/packages/editor/src/components/post-actions/test/index.browser.test.tsx @@ -51,9 +51,11 @@ describe( 'PostActions', () => { { id: 'delete', label: 'Delete', - RenderModal: ( { closeModal }: { closeModal: () => void } ) => ( - - ), + RenderModal: ( { + closeModal, + }: { + closeModal?: () => void; + } ) => , }, ] ); await renderPostActions(); diff --git a/packages/editor/src/components/post-fields/index.ts b/packages/editor/src/components/post-fields/index.ts index 31646987f92721..fc0f32f4fe1d37 100644 --- a/packages/editor/src/components/post-fields/index.ts +++ b/packages/editor/src/components/post-fields/index.ts @@ -12,8 +12,8 @@ function usePostFields( { }: { postType: string; } ): Field< BasePostWithEmbeddedAuthor >[] { - // The fields come from the server. The schema still registers the - // actions of the post type, and fires `core.registerPostTypeSchema`. + // The fields come from the server. The schema only fires + // `core.registerPostTypeSchema`, for the code listening to it. const { registerPostTypeSchema } = unlock( useDispatch( editorStore ) ); useEffect( () => { registerPostTypeSchema( postType ); diff --git a/packages/editor/src/components/post-fields/test/index.jsdom.test.tsx b/packages/editor/src/components/post-fields/test/index.jsdom.test.tsx index 178712d574612a..e6f1482a490852 100644 --- a/packages/editor/src/components/post-fields/test/index.jsdom.test.tsx +++ b/packages/editor/src/components/post-fields/test/index.jsdom.test.tsx @@ -12,8 +12,8 @@ vi.hoisted( () => globalThis.wpVitest.mockMatchMedia() ); vi.mock( import( '@wordpress/api-fetch' ) ); /** - * Leaves the requests other than the fields' pending, such as those of the - * post type `registerPostTypeSchema` makes, which these tests do not need. + * Leaves the requests other than the fields' pending, which these tests do + * not need. * * @return A promise that never settles. */ diff --git a/packages/editor/src/dataviews/api.js b/packages/editor/src/dataviews/api.js index 0ac69e4538fb27..8fbaaa18b0e02b 100644 --- a/packages/editor/src/dataviews/api.js +++ b/packages/editor/src/dataviews/api.js @@ -1,60 +1,59 @@ -import { dispatch } from '@wordpress/data'; import deprecated from '@wordpress/deprecated'; -import { unlock } from '../lock-unlock'; -import { store as editorStore } from '../store'; /** * @typedef {import('@wordpress/dataviews').Action} Action * @typedef {import('@wordpress/dataviews').Field} Field */ +/* + * The functions below keep their parameters so an existing call still type + * checks, and ignore them. + */ +/* eslint-disable no-unused-vars */ + /** - * Registers a new DataViews action. + * Has no effect: every action of an entity is registered on the server. + * + * Register the action in PHP on the `wp_entity_actions_api_init` action instead, + * with a script module for the parts PHP cannot serialize (`callback`, + * `RenderModal`, `isEligible`…). * - * This is an experimental API and is subject to change. - * it's only available in the Gutenberg plugin for now. + * @deprecated since Gutenberg 24.3. Register the action in PHP on the + * `wp_entity_actions_api_init` action instead, with a script module for + * its JavaScript parts. * * @param {string} kind Entity kind. * @param {string} name Entity name. * @param {Action} config Action configuration. */ - export function registerEntityAction( kind, name, config ) { - const { registerEntityAction: _registerEntityAction } = unlock( - dispatch( editorStore ) - ); - - if ( globalThis.IS_GUTENBERG_PLUGIN ) { - _registerEntityAction( kind, name, config ); - } + deprecated( 'wp.editor.registerEntityAction', { + since: '24.3', + plugin: 'Gutenberg', + alternative: 'the `wp_entity_actions_api_init` PHP action', + } ); } /** - * Unregisters a DataViews action. + * Has no effect: every action of an entity is registered on the server. * - * This is an experimental API and is subject to change. - * it's only available in the Gutenberg plugin for now. + * Unregister the action in PHP on the `wp_entity_actions_api_init` action instead. + * + * @deprecated since Gutenberg 24.3. Unregister the action in PHP on the + * `wp_entity_actions_api_init` action instead. * * @param {string} kind Entity kind. * @param {string} name Entity name. * @param {string} actionId Action ID. */ export function unregisterEntityAction( kind, name, actionId ) { - const { unregisterEntityAction: _unregisterEntityAction } = unlock( - dispatch( editorStore ) - ); - - if ( globalThis.IS_GUTENBERG_PLUGIN ) { - _unregisterEntityAction( kind, name, actionId ); - } + deprecated( 'wp.editor.unregisterEntityAction', { + since: '24.3', + plugin: 'Gutenberg', + alternative: 'the `wp_entity_actions_api_init` PHP action', + } ); } -/* - * Both functions below keep their parameters so an existing call still type - * checks, and ignore them. - */ -/* eslint-disable no-unused-vars */ - /** * Has no effect: the fields of an entity are registered on the server. * diff --git a/packages/editor/src/dataviews/store/private-actions.ts b/packages/editor/src/dataviews/store/private-actions.ts index ff19eb43131bc0..49d317390e470d 100644 --- a/packages/editor/src/dataviews/store/private-actions.ts +++ b/packages/editor/src/dataviews/store/private-actions.ts @@ -1,36 +1,8 @@ -import { store as coreStore } from '@wordpress/core-data'; -import type { Action, Field } from '@wordpress/dataviews'; +import type { Field } from '@wordpress/dataviews'; import { doAction } from '@wordpress/hooks'; -import type { PostType } from '../types'; import { store as editorStore } from '../../store'; import { unlock } from '../../lock-unlock'; -export function registerEntityAction< Item >( - kind: string, - name: string, - config: Action< Item > -) { - return { - type: 'REGISTER_ENTITY_ACTION' as const, - kind, - name, - config, - }; -} - -export function unregisterEntityAction( - kind: string, - name: string, - actionId: string -) { - return { - type: 'UNREGISTER_ENTITY_ACTION' as const, - kind, - name, - actionId, - }; -} - /** * Receives the fields of an entity, as the `getEntityFields` resolver * resolves them from the server. @@ -62,62 +34,17 @@ export function setIsReady( kind: string, name: string ) { } /** - * Reads the actions of a settled import of a core actions script module. + * Fires the `core.registerPostTypeSchema` hook once for each post type. * - * A module that failed to load is reported and yields no actions, so the - * post type still gets the actions of the other modules. + * The fields and the actions of a post type come from the server, so there + * is nothing left to register here. The hook still fires for the code that + * listens to it. * - * @param result The settled import of the module, or of nothing when the - * post type does not need the module. * @param postType The post type. - * @return The actions of the module, keyed by action id. */ -function getLoadedActions< Module extends { default: object } >( - result: PromiseSettledResult< Module | undefined >, - postType: string -): Partial< Module[ 'default' ] > { - if ( result.status === 'rejected' ) { - // eslint-disable-next-line no-console - console.warn( - `Could not load the core actions of the ${ postType } post type.`, - result.reason - ); - return {}; - } - return result.value?.default ?? {}; -} - -/** - * Imports the script modules of the core actions a post type may get: the - * default actions, and the actions of patterns and template parts. - * - * The literal `import()`s keep the modules as dependencies of the editor - * script, and their types check the action ids the schema reads below. - * - * @param postType The post type. - * @return The actions of each module, keyed by action id. - */ -async function importCoreActionModules( postType: string ) { - const [ postTypeSupports, wpBlock, wpTemplatePart ] = - await Promise.allSettled( [ - import( '@wordpress/core-actions/post_type_supports' ), - postType === 'wp_block' - ? import( '@wordpress/core-actions/wp_block' ) - : undefined, - postType === 'wp_template_part' - ? import( '@wordpress/core-actions/wp_template_part' ) - : undefined, - ] ); - return [ - getLoadedActions( postTypeSupports, postType ), - getLoadedActions( wpBlock, postType ), - getLoadedActions( wpTemplatePart, postType ), - ] as const; -} - export const registerPostTypeSchema = ( postType: string ) => - async ( { registry }: { registry: any } ) => { + ( { registry }: { registry: any } ) => { const isReady = unlock( registry.select( editorStore ) ).isEntityReady( 'postType', postType @@ -131,96 +58,5 @@ export const registerPostTypeSchema = postType ); - // Start importing the script modules of the core actions while the - // post type, the permissions, and the theme resolve. - const coreActionModules = importCoreActionModules( postType ); - - const postTypeConfig = ( await registry - .resolveSelect( coreStore ) - .getPostType( postType ) ) as PostType; - - const canCreate = await registry - .resolveSelect( coreStore ) - .canUser( 'create', { - kind: 'postType', - name: postType, - } ); - const currentTheme = await registry - .resolveSelect( coreStore ) - .getCurrentTheme(); - - const [ - postTypeSupportsActions, - wpBlockActions, - wpTemplatePartActions, - ] = await coreActionModules; - const { - 'view-post': viewPost, - 'view-post-revisions': viewPostRevisions, - 'duplicate-post': duplicatePost, - 'rename-post': renamePost, - 'order-pages': reorderPage, - restore: restorePost, - 'reset-post': resetPost, - 'delete-post': deletePost, - 'move-to-trash': trashPost, - 'permanently-delete': permanentlyDeletePost, - } = postTypeSupportsActions; - const { - 'duplicate-pattern': duplicatePattern, - 'export-pattern': exportPattern, - } = wpBlockActions; - const { 'duplicate-template-part': duplicateTemplatePart } = - wpTemplatePartActions; - - let canDuplicate = - ! [ 'wp_block', 'wp_template_part', 'wp_template' ].includes( - postTypeConfig.slug - ) && - canCreate && - duplicatePost; - - // @ts-expect-error `globalThis` has no index signature for this build-time global. - if ( ! globalThis.IS_GUTENBERG_PLUGIN ) { - // Outside Gutenberg, disable duplication. - canDuplicate = undefined; - } - - const actions = [ - postTypeConfig.viewable ? viewPost : undefined, - !! postTypeConfig.supports?.revisions - ? viewPostRevisions - : undefined, - canDuplicate, - postTypeConfig.slug === 'wp_template_part' && - canCreate && - currentTheme?.is_block_theme - ? duplicateTemplatePart - : undefined, - canCreate && postTypeConfig.slug === 'wp_block' - ? duplicatePattern - : undefined, - postTypeConfig.supports?.title ? renamePost : undefined, - postTypeConfig.supports?.[ 'page-attributes' ] - ? reorderPage - : undefined, - postTypeConfig.slug === 'wp_block' ? exportPattern : undefined, - restorePost, - resetPost, - deletePost, - trashPost, - permanentlyDeletePost, - ].filter( Boolean ); - - registry.batch( () => { - actions.forEach( ( action ) => { - unlock( registry.dispatch( editorStore ) ).registerEntityAction( - 'postType', - postType, - action - ); - } ); - } ); - doAction( 'core.registerPostTypeSchema', postType ); }; diff --git a/packages/editor/src/dataviews/store/private-selectors.ts b/packages/editor/src/dataviews/store/private-selectors.ts index 5763dd98214298..9d6f5ca2525f73 100644 --- a/packages/editor/src/dataviews/store/private-selectors.ts +++ b/packages/editor/src/dataviews/store/private-selectors.ts @@ -2,10 +2,6 @@ import type { State } from './reducer'; const EMPTY_ARRAY = [] as []; -export function getEntityActions( state: State, kind: string, name: string ) { - return state.actions[ kind ]?.[ name ] ?? EMPTY_ARRAY; -} - export function getEntityFields( state: State, kind: string, name: string ) { return state.fields[ kind ]?.[ name ] ?? EMPTY_ARRAY; } diff --git a/packages/editor/src/dataviews/store/reducer.ts b/packages/editor/src/dataviews/store/reducer.ts index 2ba796fd73391a..04e3c92daa2c63 100644 --- a/packages/editor/src/dataviews/store/reducer.ts +++ b/packages/editor/src/dataviews/store/reducer.ts @@ -1,17 +1,13 @@ import { combineReducers } from '@wordpress/data'; -import type { Action, Field } from '@wordpress/dataviews'; +import type { Field } from '@wordpress/dataviews'; type ReduxAction = - | ReturnType< typeof import( './private-actions' ).registerEntityAction > - | ReturnType< typeof import( './private-actions' ).unregisterEntityAction > | ReturnType< typeof import( './private-actions' ).receiveEntityFields > | ReturnType< typeof import( './private-actions' ).setIsReady >; -export type ActionState = Record< string, Record< string, Action< any >[] > >; export type FieldsState = Record< string, Record< string, Field< any >[] > >; export type ReadyState = Record< string, Record< string, boolean > >; export type State = { - actions: ActionState; fields: FieldsState; isReady: ReadyState; }; @@ -31,39 +27,6 @@ function isReady( state: ReadyState = {}, action: ReduxAction ) { return state; } -function actions( state: ActionState = {}, action: ReduxAction ) { - switch ( action.type ) { - case 'REGISTER_ENTITY_ACTION': - return { - ...state, - [ action.kind ]: { - ...state[ action.kind ], - [ action.name ]: [ - ...( - state[ action.kind ]?.[ action.name ] ?? [] - ).filter( - ( _action ) => _action.id !== action.config.id - ), - action.config, - ], - }, - }; - case 'UNREGISTER_ENTITY_ACTION': { - return { - ...state, - [ action.kind ]: { - ...state[ action.kind ], - [ action.name ]: ( - state[ action.kind ]?.[ action.name ] ?? [] - ).filter( ( _action ) => _action.id !== action.actionId ), - }, - }; - } - } - - return state; -} - /* * The fields of each entity, keyed by kind, then name, as the * `getEntityFields` resolver receives them from the server. @@ -84,7 +47,6 @@ function fields( state: FieldsState = {}, action: ReduxAction ) { } export default combineReducers( { - actions, fields, isReady, } ); diff --git a/packages/editor/src/dataviews/store/test/private-actions.jsdom.test.ts b/packages/editor/src/dataviews/store/test/private-actions.jsdom.test.ts deleted file mode 100644 index 269f0b9e3597e6..00000000000000 --- a/packages/editor/src/dataviews/store/test/private-actions.jsdom.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createRegistry } from '@wordpress/data'; -import { store as coreStore } from '@wordpress/core-data'; -import postTypeSupports from '@wordpress/core-actions/post_type_supports'; -import wpBlock from '@wordpress/core-actions/wp_block'; -import type { Action } from '@wordpress/dataviews'; -import { store as editorStore } from '../../../store'; -import { lock, unlock } from '../../../lock-unlock'; -import { registerPostTypeSchema } from '../private-actions'; - -vi.hoisted( () => globalThis.wpVitest.mockMatchMedia() ); - -const POST_TYPES = { - page: { - slug: 'page', - viewable: true, - supports: { - title: true, - editor: true, - revisions: true, - 'page-attributes': true, - }, - }, - wp_block: { - slug: 'wp_block', - viewable: false, - supports: { title: true, editor: true, revisions: true }, - }, - wp_template_part: { - slug: 'wp_template_part', - viewable: false, - supports: { title: true, revisions: true }, - }, -}; - -/** - * Runs `registerPostTypeSchema()` against a registry that resolves the post - * type, lets the user create posts, and has a block theme active. - * - * @param postType The post type. - * @return The actions registered for the post type. - */ -async function register( postType: keyof typeof POST_TYPES ) { - const actions: Action< any >[] = []; - const editorSelectors = {}; - lock( editorSelectors, { isEntityReady: () => false } ); - const editorActions = {}; - lock( editorActions, { - setIsReady: () => {}, - registerEntityAction: ( - kind: string, - name: string, - action: Action< any > - ) => actions.push( action ), - } ); - const registry = { - select: () => editorSelectors, - dispatch: () => editorActions, - resolveSelect: () => ( { - getPostType: async () => POST_TYPES[ postType ], - canUser: async () => true, - getCurrentTheme: async () => ( { is_block_theme: true } ), - } ), - batch: ( callback: () => void ) => callback(), - }; - await registerPostTypeSchema( postType )( { registry } ); - return { actions, actionIds: actions.map( ( { id } ) => id ) }; -} - -function registerSchema( postType: string, supports: Record< string, any > ) { - const registry = createRegistry(); - registry.register( editorStore ); - // Answers the three requests the schema makes, without the REST API. - const resolveSelect = ( store: any ) => - store === coreStore - ? { - getPostType: async () => ( { - slug: postType, - viewable: true, - supports, - } ), - canUser: async () => true, - getCurrentTheme: async () => ( { - is_block_theme: true, - theme_supports: { 'post-thumbnails': true }, - } ), - } - : registry.resolveSelect( store ); - return registerPostTypeSchema( postType )( { - registry: { ...registry, resolveSelect }, - } ).then( () => unlock( registry.select( editorStore ) ) ); -} - -describe( 'registerPostTypeSchema', () => { - afterEach( async () => { - // Undo the mock of the last test. Like the mock, it takes effect on the - // next import from this file. - vi.doUnmock( import( '@wordpress/core-actions/wp_template_part' ) ); - await import( '@wordpress/core-actions/wp_template_part' ); - } ); - - it( 'registers the actions of a post type', async () => { - const { getEntityActions } = await registerSchema( 'page', { - title: true, - editor: true, - author: true, - comments: true, - } ); - - expect( - getEntityActions( 'postType', 'page' ).map( - ( { id }: { id: string } ) => id - ) - ).toContain( 'rename-post' ); - } ); - - it( 'registers the default actions from their script module', async () => { - const { actions, actionIds } = await register( 'page' ); - - expect( actionIds ).toEqual( [ - 'view-post', - 'view-post-revisions', - 'duplicate-post', - 'rename-post', - 'order-pages', - 'restore', - 'reset-post', - 'delete-post', - 'move-to-trash', - 'permanently-delete', - ] ); - for ( const action of actions ) { - expect( Object.values( postTypeSupports ) ).toContain( action ); - } - } ); - - it( 'registers the pattern actions from their script module', async () => { - const { actions, actionIds } = await register( 'wp_block' ); - - expect( actionIds ).toEqual( [ - 'view-post-revisions', - 'duplicate-pattern', - 'rename-post', - 'export-pattern', - 'restore', - 'reset-post', - 'delete-post', - 'move-to-trash', - 'permanently-delete', - ] ); - expect( actions ).toContain( wpBlock[ 'duplicate-pattern' ] ); - expect( actions ).toContain( wpBlock[ 'export-pattern' ] ); - } ); - - it( 'registers the template part actions from their script module', async () => { - const { default: wpTemplatePart } = - await import( '@wordpress/core-actions/wp_template_part' ); - const { actions, actionIds } = await register( 'wp_template_part' ); - - expect( actionIds ).toEqual( [ - 'view-post-revisions', - 'duplicate-template-part', - 'rename-post', - 'restore', - 'reset-post', - 'delete-post', - 'move-to-trash', - 'permanently-delete', - ] ); - expect( actions ).toContain( - wpTemplatePart[ 'duplicate-template-part' ] - ); - } ); - - it( 'registers the other actions when a script module fails to load', async () => { - // The script module of the template part actions fails to load. A mock - // queued by `vi.doMock()` only takes effect once this file imports a - // module, so import it here before the schema does. - vi.doMock( import( '@wordpress/core-actions/wp_template_part' ), () => - Promise.reject( new Error( 'Failed to fetch' ) ) - ); - await expect( - import( '@wordpress/core-actions/wp_template_part' ) - ).rejects.toThrow(); - - const { actionIds } = await register( 'wp_template_part' ); - - expect( actionIds ).toEqual( [ - 'view-post-revisions', - 'rename-post', - 'restore', - 'reset-post', - 'delete-post', - 'move-to-trash', - 'permanently-delete', - ] ); - expect( console ).toHaveWarnedWith( - 'Could not load the core actions of the wp_template_part post type.', - expect.any( Error ) - ); - } ); -} ); diff --git a/packages/editor/src/store/private-selectors.js b/packages/editor/src/store/private-selectors.js index af7becea866264..1a80e611cceef5 100644 --- a/packages/editor/src/store/private-selectors.js +++ b/packages/editor/src/store/private-selectors.js @@ -21,7 +21,6 @@ import { getCurrentPostRevisionsCount, } from './selectors'; import { - getEntityActions as _getEntityActions, getEntityFields as _getEntityFields, isEntityReady as _isEntityReady, } from '../dataviews/store/private-selectors'; @@ -170,10 +169,6 @@ export const getPostIcon = createRegistrySelector( } ); -export function getEntityActions( state, ...args ) { - return _getEntityActions( state.dataviews, ...args ); -} - export function isEntityReady( state, ...args ) { return _isEntityReady( state.dataviews, ...args ); } diff --git a/packages/editor/tsconfig.build.json b/packages/editor/tsconfig.build.json index 890be2da83d61a..927a775f6e7a52 100644 --- a/packages/editor/tsconfig.build.json +++ b/packages/editor/tsconfig.build.json @@ -15,7 +15,6 @@ { "path": "../commands/tsconfig.build.json" }, { "path": "../components/tsconfig.build.json" }, { "path": "../compose/tsconfig.build.json" }, - { "path": "../core-actions/tsconfig.build.json" }, { "path": "../core-data/tsconfig.build.json" }, { "path": "../data" }, { "path": "../dataviews/tsconfig.build.json" }, diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json index fc36d760aefb01..d8ffb25cbe0bc0 100644 --- a/packages/editor/tsconfig.json +++ b/packages/editor/tsconfig.json @@ -20,7 +20,6 @@ { "path": "../commands/tsconfig.build.json" }, { "path": "../components/tsconfig.build.json" }, { "path": "../compose/tsconfig.build.json" }, - { "path": "../core-actions/tsconfig.build.json" }, { "path": "../core-data/tsconfig.build.json" }, { "path": "../data" }, { "path": "../dataviews/tsconfig.build.json" }, diff --git a/test/unit/vitest.config.mjs b/test/unit/vitest.config.mjs index 627b3122327bb9..9d8431c1f7c056 100644 --- a/test/unit/vitest.config.mjs +++ b/test/unit/vitest.config.mjs @@ -103,13 +103,6 @@ export default defineConfig( { 'test/unit/config/video-conversion-worker-code-stub.vitest.js' ), }, - { - find: /^@wordpress\/core-actions\/([^/]+)$/, - replacement: path.join( - ROOT_DIR, - 'packages/core-actions/src/$1' - ), - }, { find: /^@wordpress\/([^/]+)\/src\/(.+)$/, replacement: path.join( ROOT_DIR, 'packages/$1/src/$2' ), From da2e08e4b59f36018699806c42c6c7b1f4c444a1 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 13 Oct 2026 22:01:36 +0100 Subject: [PATCH 07/20] Core Actions: Keep only the JavaScript parts in the script modules The data of each action is in its `action.php` and the server registers it, so remove it from the `action.tsx` next to it: the id, the string labels, and the flags and modal options. Each module now maps the id of each action to what PHP cannot serialize: its icon, its eligibility check, its callback or modal, and the label of `view-post-revisions`, which depends on the items. --- packages/core-actions/README.md | 33 ++++++++++++++++--- .../post_type_supports/delete-post/action.tsx | 21 ++++++------ .../duplicate-post/action.tsx | 16 ++++----- .../src/post_type_supports/index.ts | 12 ++++--- .../move-to-trash/action.tsx | 18 +++++----- .../post_type_supports/order-pages/action.tsx | 17 +++++----- .../permanently-delete/action.tsx | 17 +++++----- .../post_type_supports/rename-post/action.tsx | 16 ++++----- .../post_type_supports/reset-post/action.tsx | 18 +++++----- .../src/post_type_supports/restore/action.tsx | 16 ++++----- .../view-post-revisions/action.tsx | 10 +++--- .../post_type_supports/view-post/action.tsx | 12 +++---- packages/core-actions/src/shared/types.ts | 19 +++++++++++ .../src/test/post-type-supports.jsdom.test.ts | 11 +++++-- .../src/test/wp-block.jsdom.test.ts | 11 +++++-- .../src/test/wp-template-part.jsdom.test.ts | 11 +++++-- .../src/wp_block/duplicate-pattern/action.tsx | 12 +++---- .../src/wp_block/export-pattern/action.tsx | 11 +++---- packages/core-actions/src/wp_block/index.ts | 7 ++-- .../duplicate-template-part/action.tsx | 12 +++---- .../src/wp_template_part/index.ts | 7 ++-- 21 files changed, 174 insertions(+), 133 deletions(-) diff --git a/packages/core-actions/README.md b/packages/core-actions/README.md index c6e5996c07bbe0..18e96d14bf157a 100644 --- a/packages/core-actions/README.md +++ b/packages/core-actions/README.md @@ -2,7 +2,7 @@ The actions WordPress core registers for its entities, such as "Rename", "Duplicate" or "Move to trash". They are shown in the DataViews lists of the site editor and in the inspector of the editor. -Each collection of actions is a script module, which `registerPostTypeSchema()` of `@wordpress/editor` imports on demand to register the actions of a post type. The actions moved here from `@wordpress/fields`. +WordPress registers them on the server through the entity actions API, like a plugin registers its own: the data of each action, such as its label, is registered in PHP, and its JavaScript parts, such as its callback or its modal, are in a script module. The editor reads the actions of an entity from the `/wp/v2/entity-actions` REST API route, imports the script modules the route lists, and merges each action with its JavaScript parts. The actions moved here from `@wordpress/fields`. ## Collections @@ -12,9 +12,9 @@ The actions of this package live in folders of `src`, one per collection: - `wp_block`: the actions patterns have on top of the defaults (`duplicate-pattern` and `export-pattern`). - `wp_template_part`: the actions template parts have on top of the defaults (`duplicate-template-part`). -Each action has a folder named after its id, with the action in `action.tsx`. The `index.ts` of each collection is its script module, whose default export maps the id of each action to the action. +Each action has a folder named after its id, with the data of the action in `action.php` and its JavaScript parts in `action.tsx`. The `index.ts` of each collection is its script module, whose default export maps the id of each action to its JavaScript parts. -`registerPostTypeSchema()` decides which post type gets which action: +`src/index.php` registers the actions of each post type exposed in the REST API, on the `wp_entity_actions_api_init` action at priority 0, so a plugin hooking it at the default priority can remove or replace them: - `view-post`, for the viewable post types. - `view-post-revisions`, for the post types supporting `revisions`. @@ -26,6 +26,8 @@ Each action has a folder named after its id, with the action in `action.tsx`. Th - `export-pattern`, for patterns. - `restore`, `reset-post`, `delete-post`, `move-to-trash`, and `permanently-delete`, for every post type. Each one checks which posts it applies to. +The actions are registered in that order, which is the order they are shown in, for the current user: the actions depending on what the user can do are only registered for the users who can. The Gutenberg plugin copies the PHP files into its build, with its `gutenberg_` prefix. + ## Prerequisites The modules use the `wp.*` globals the `wp-editor` script loads, such as `wp.data` and `wp.components`, so they only run on pages that load the editor script. They are dynamic module dependencies of `wp-editor`, which adds them to the import map of every page loading it. @@ -42,13 +44,34 @@ _This package assumes that your code will run in an ES2015+ environment. If you' ## Usage -The package provides the `@wordpress/core-actions/post_type_supports`, `@wordpress/core-actions/wp_block`, and `@wordpress/core-actions/wp_template_part` script modules. Their default export maps the id of each action to a DataViews action: +The package provides the `@wordpress/core-actions/post_type_supports`, `@wordpress/core-actions/wp_block`, and `@wordpress/core-actions/wp_template_part` script modules. Their default export maps the id of each action to the properties of the DataViews action that PHP cannot serialize, which complete the action the `/wp/v2/entity-actions` route returns: ```js const { default: actions } = await import( '@wordpress/core-actions/post_type_supports' ); -const renamePost = actions[ 'rename-post' ]; +const renamePost = { ...actionFromTheServer, ...actions[ 'rename-post' ] }; +``` + +A plugin replacing a core action registers its own definition with the script module of the core action, so the action keeps its JavaScript parts: + +```php +add_action( 'wp_entity_actions_api_init', function () { + wp_unregister_entity_actions( 'postType', 'page', array( 'move-to-trash' ) ); + wp_register_entity_actions( + 'acme', + 'postType', + 'page', + array( + array( + 'id' => 'move-to-trash', + 'label' => __( 'Move to the bin…', 'acme' ), + 'supportsBulk' => true, + ), + ), + '@wordpress/core-actions/post_type_supports' + ); +} ); ``` ## Contributing to this package diff --git a/packages/core-actions/src/post_type_supports/delete-post/action.tsx b/packages/core-actions/src/post_type_supports/delete-post/action.tsx index be239d78840f6c..6431c9735344f2 100644 --- a/packages/core-actions/src/post_type_supports/delete-post/action.tsx +++ b/packages/core-actions/src/post_type_supports/delete-post/action.tsx @@ -9,14 +9,18 @@ import { } from '@wordpress/components'; // @ts-expect-error `@wordpress/patterns` is not typed yet. import { privateApis as patternsPrivateApis } from '@wordpress/patterns'; -import type { Action } from '@wordpress/dataviews'; import { decodeEntities } from '@wordpress/html-entities'; import { getItemTitle, isTemplateOrTemplatePart, isTemplateRemovable, } from '../../shared/utils'; -import type { Pattern, Template, TemplatePart } from '../../shared/types'; +import type { + Pattern, + Template, + TemplatePart, + ActionScriptParts, +} from '../../shared/types'; import type { NoticeSettings } from './delete-post-with-notices'; import { deletePostWithNotices } from './delete-post-with-notices'; import { unlock } from '../../lock-unlock'; @@ -26,10 +30,7 @@ const { PATTERN_TYPES } = unlock( patternsPrivateApis ); // This action is used for templates, patterns and template parts. // Every other post type uses the similar `trashPostAction` which // moves the post to trash. -const deletePostAction: Action< Template | TemplatePart | Pattern > = { - id: 'delete-post', - label: __( 'Delete…' ), - isPrimary: true, +const deletePost: ActionScriptParts< Template | TemplatePart | Pattern > = { icon: trash, isEligible( post ) { if ( isTemplateOrTemplatePart( post ) ) { @@ -38,9 +39,6 @@ const deletePostAction: Action< Template | TemplatePart | Pattern > = { // We can only remove user patterns. return post.type === PATTERN_TYPES.user; }, - supportsBulk: true, - hideModalHeader: true, - modalFocusOnMount: 'firstContentElement', RenderModal: ( { items, closeModal, onActionPerformed } ) => { const [ isBusy, setIsBusy ] = useState( false ); const isResetting = items.every( @@ -198,6 +196,7 @@ const deletePostAction: Action< Template | TemplatePart | Pattern > = { }; /** - * Delete action for Templates, Patterns and Template Parts. + * The JavaScript parts of the delete action for Templates, Patterns and + * Template Parts; its data is in `action.php`. */ -export default deletePostAction; +export default deletePost; diff --git a/packages/core-actions/src/post_type_supports/duplicate-post/action.tsx b/packages/core-actions/src/post_type_supports/duplicate-post/action.tsx index 6afc3413244b92..948f5e98505439 100644 --- a/packages/core-actions/src/post_type_supports/duplicate-post/action.tsx +++ b/packages/core-actions/src/post_type_supports/duplicate-post/action.tsx @@ -9,18 +9,17 @@ import { __experimentalVStack as VStack, __experimentalInputControl as WCInputControl, } from '@wordpress/components'; -import type { Action } from '@wordpress/dataviews'; -import type { BasePost, CoreDataError } from '../../shared/types'; +import type { + BasePost, + CoreDataError, + ActionScriptParts, +} from '../../shared/types'; import { getItemTitle } from '../../shared/utils'; -const duplicatePost: Action< BasePost > = { - id: 'duplicate-post', - label: _x( 'Duplicate…', 'action label' ), - modalHeader: _x( 'Duplicate', 'action label' ), +const duplicatePost: ActionScriptParts< BasePost > = { isEligible( { status } ) { return status !== 'trash'; }, - modalFocusOnMount: 'firstContentElement', RenderModal: ( { items, closeModal, onActionPerformed } ) => { const [ item, setItem ] = useState< BasePost >( { ...items[ 0 ], @@ -160,6 +159,7 @@ const duplicatePost: Action< BasePost > = { }; /** - * Duplicate action for BasePost. + * The JavaScript parts of the duplicate action for BasePost; its data is in + * `action.php`. */ export default duplicatePost; diff --git a/packages/core-actions/src/post_type_supports/index.ts b/packages/core-actions/src/post_type_supports/index.ts index 3d14e1edb6040d..31a36a6c06802e 100644 --- a/packages/core-actions/src/post_type_supports/index.ts +++ b/packages/core-actions/src/post_type_supports/index.ts @@ -3,12 +3,14 @@ * `@wordpress/core-actions/post_type_supports`. * * The default actions of every post type, each registered on the post types - * that support it or on every post type, see `registerPostTypeSchema()` in - * `@wordpress/editor`. Its default export maps the id of each action to the - * action. + * that support it or on every post type. The data of each action is in its + * `action.php`, and `index.php` registers the actions; the client reads them + * from the `wp/v2/entity-actions` route. What cannot be serialized (callbacks and + * components) is in the `action.tsx` of the action: the client imports this + * module on demand and merges each entry into the action with the same id. */ -import type { Action } from '@wordpress/dataviews'; +import type { ActionScriptParts } from '../shared/types'; import deletePost from './delete-post/action'; import duplicatePost from './duplicate-post/action'; import moveToTrash from './move-to-trash/action'; @@ -31,6 +33,6 @@ const actions = { restore, 'view-post': viewPost, 'view-post-revisions': viewPostRevisions, -} satisfies Record< string, Action< any > >; +} satisfies Record< string, ActionScriptParts< any > >; export default actions; diff --git a/packages/core-actions/src/post_type_supports/move-to-trash/action.tsx b/packages/core-actions/src/post_type_supports/move-to-trash/action.tsx index 8b212f64e09e94..69925bc0f15d33 100644 --- a/packages/core-actions/src/post_type_supports/move-to-trash/action.tsx +++ b/packages/core-actions/src/post_type_supports/move-to-trash/action.tsx @@ -10,15 +10,15 @@ import { __experimentalHStack as HStack, __experimentalVStack as VStack, } from '@wordpress/components'; -import type { Action } from '@wordpress/dataviews'; import { getItemTitle } from '../../shared/utils'; import { hasActionLink } from '../../shared/has-action-link'; -import type { CoreDataError, PostWithPermissions } from '../../shared/types'; +import type { + CoreDataError, + PostWithPermissions, + ActionScriptParts, +} from '../../shared/types'; -const trashPost: Action< PostWithPermissions > = { - id: 'move-to-trash', - label: _x( 'Trash…', 'verb' ), - isPrimary: true, +const trashPost: ActionScriptParts< PostWithPermissions > = { icon: trash, isEligible( item ) { if ( item.type === 'wp_template_part' || item.type === 'wp_block' ) { @@ -37,9 +37,6 @@ const trashPost: Action< PostWithPermissions > = { hasActionLink( item, 'wp:action-trash' ) ); }, - supportsBulk: true, - hideModalHeader: true, - modalFocusOnMount: 'firstContentElement', RenderModal: ( { items, closeModal, onActionPerformed } ) => { const [ isBusy, setIsBusy ] = useState( false ); const { createSuccessNotice, createErrorNotice } = @@ -197,6 +194,7 @@ const trashPost: Action< PostWithPermissions > = { }; /** - * Trash action for PostWithPermissions. + * The JavaScript parts of the trash action for PostWithPermissions; its data is + * in `action.php`. */ export default trashPost; diff --git a/packages/core-actions/src/post_type_supports/order-pages/action.tsx b/packages/core-actions/src/post_type_supports/order-pages/action.tsx index abcb0a2452e12a..79c2cbfed4eee9 100644 --- a/packages/core-actions/src/post_type_supports/order-pages/action.tsx +++ b/packages/core-actions/src/post_type_supports/order-pages/action.tsx @@ -9,8 +9,12 @@ import { __experimentalVStack as VStack, __experimentalInputControl as WCInputControl, } from '@wordpress/components'; -import type { Action, RenderModalProps } from '@wordpress/dataviews'; -import type { CoreDataError, BasePost } from '../../shared/types'; +import type { RenderModalProps } from '@wordpress/dataviews'; +import type { + CoreDataError, + BasePost, + ActionScriptParts, +} from '../../shared/types'; function isItemValid( item: BasePost ): boolean { return ( @@ -114,18 +118,15 @@ function ReorderModal( { ); } -const reorderPage: Action< BasePost > = { - id: 'order-pages', - label: __( 'Order…' ), - modalHeader: __( 'Order' ), +const reorderPage: ActionScriptParts< BasePost > = { isEligible( { status } ) { return status !== 'trash'; }, - modalFocusOnMount: 'firstContentElement', RenderModal: ReorderModal, }; /** - * Reorder action for BasePost. + * The JavaScript parts of the reorder action for BasePost; its data is in + * `action.php`. */ export default reorderPage; diff --git a/packages/core-actions/src/post_type_supports/permanently-delete/action.tsx b/packages/core-actions/src/post_type_supports/permanently-delete/action.tsx index 41d8c7c6ff629a..c2e85e784487ba 100644 --- a/packages/core-actions/src/post_type_supports/permanently-delete/action.tsx +++ b/packages/core-actions/src/post_type_supports/permanently-delete/action.tsx @@ -1,7 +1,6 @@ import { store as coreStore } from '@wordpress/core-data'; import { __, _n, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; -import type { Action } from '@wordpress/dataviews'; import { trash } from '@wordpress/icons'; import { useState } from '@wordpress/element'; import { useDispatch } from '@wordpress/data'; @@ -14,12 +13,13 @@ import { import { decodeEntities } from '@wordpress/html-entities'; import { getItemTitle, isTemplateOrTemplatePart } from '../../shared/utils'; import { hasActionLink } from '../../shared/has-action-link'; -import type { CoreDataError, PostWithPermissions } from '../../shared/types'; +import type { + CoreDataError, + PostWithPermissions, + ActionScriptParts, +} from '../../shared/types'; -const permanentlyDeletePost: Action< PostWithPermissions > = { - id: 'permanently-delete', - label: __( 'Permanently delete…' ), - supportsBulk: true, +const permanentlyDeletePost: ActionScriptParts< PostWithPermissions > = { icon: trash, isEligible( item ) { if ( isTemplateOrTemplatePart( item ) || item.type === 'wp_block' ) { @@ -33,8 +33,6 @@ const permanentlyDeletePost: Action< PostWithPermissions > = { // Without the trash link, deleting is permanent. return status === 'trash' || ! hasActionLink( item, 'wp:action-trash' ); }, - hideModalHeader: true, - modalFocusOnMount: 'firstContentElement', RenderModal: ( { items, closeModal, onActionPerformed } ) => { const [ isBusy, setIsBusy ] = useState( false ); const { createSuccessNotice, createErrorNotice } = @@ -187,6 +185,7 @@ const permanentlyDeletePost: Action< PostWithPermissions > = { }; /** - * Delete action for PostWithPermissions. + * The JavaScript parts of the delete action for PostWithPermissions; its data + * is in `action.php`. */ export default permanentlyDeletePost; diff --git a/packages/core-actions/src/post_type_supports/rename-post/action.tsx b/packages/core-actions/src/post_type_supports/rename-post/action.tsx index 72555f6cbffbc2..179b7b49684d51 100644 --- a/packages/core-actions/src/post_type_supports/rename-post/action.tsx +++ b/packages/core-actions/src/post_type_supports/rename-post/action.tsx @@ -10,7 +10,6 @@ import { __experimentalHStack as HStack, __experimentalVStack as VStack, } from '@wordpress/components'; -import type { Action } from '@wordpress/dataviews'; import { store as noticesStore } from '@wordpress/notices'; import { unlock } from '../../lock-unlock'; import { @@ -19,16 +18,16 @@ import { isTemplate, isTemplatePart, } from '../../shared/utils'; -import type { CoreDataError, PostWithPermissions } from '../../shared/types'; +import type { + CoreDataError, + PostWithPermissions, + ActionScriptParts, +} from '../../shared/types'; // Patterns. const { PATTERN_TYPES } = unlock( patternsPrivateApis ); -const renamePost: Action< PostWithPermissions > = { - id: 'rename-post', - label: __( 'Rename…' ), - modalHeader: __( 'Rename' ), - modalFocusOnMount: 'firstContentElement', +const renamePost: ActionScriptParts< PostWithPermissions > = { isEligible( post ) { if ( post.status === 'trash' ) { return false; @@ -127,6 +126,7 @@ const renamePost: Action< PostWithPermissions > = { }; /** - * Rename action for PostWithPermissions. + * The JavaScript parts of the rename action for PostWithPermissions; its data + * is in `action.php`. */ export default renamePost; diff --git a/packages/core-actions/src/post_type_supports/reset-post/action.tsx b/packages/core-actions/src/post_type_supports/reset-post/action.tsx index 4bc088a5f49aff..147befd245a04d 100644 --- a/packages/core-actions/src/post_type_supports/reset-post/action.tsx +++ b/packages/core-actions/src/post_type_supports/reset-post/action.tsx @@ -11,11 +11,15 @@ import { __experimentalHStack as HStack, __experimentalVStack as VStack, } from '@wordpress/components'; -import type { Action } from '@wordpress/dataviews'; import { addQueryArgs } from '@wordpress/url'; import apiFetch from '@wordpress/api-fetch'; import { getItemTitle, isTemplateOrTemplatePart } from '../../shared/utils'; -import type { CoreDataError, Template, TemplatePart } from '../../shared/types'; +import type { + CoreDataError, + Template, + TemplatePart, + ActionScriptParts, +} from '../../shared/types'; const isTemplateRevertable = ( templateOrTemplatePart: Template | TemplatePart @@ -167,9 +171,7 @@ const revertTemplate = async ( } }; -const resetPostAction: Action< Template | TemplatePart > = { - id: 'reset-post', - label: __( 'Reset…' ), +const resetPostAction: ActionScriptParts< Template | TemplatePart > = { isEligible: ( item ) => { return ( isTemplateOrTemplatePart( item ) && @@ -179,9 +181,6 @@ const resetPostAction: Action< Template | TemplatePart > = { ); }, icon: backup, - supportsBulk: true, - hideModalHeader: true, - modalFocusOnMount: 'firstContentElement', RenderModal: ( { items, closeModal, onActionPerformed } ) => { const [ isBusy, setIsBusy ] = useState( false ); @@ -286,6 +285,7 @@ const resetPostAction: Action< Template | TemplatePart > = { }; /** - * Reset action for Template and TemplatePart. + * The JavaScript parts of the reset action for Template and TemplatePart; its + * data is in `action.php`. */ export default resetPostAction; diff --git a/packages/core-actions/src/post_type_supports/restore/action.tsx b/packages/core-actions/src/post_type_supports/restore/action.tsx index 00103c533fb457..3f89a84b97a918 100644 --- a/packages/core-actions/src/post_type_supports/restore/action.tsx +++ b/packages/core-actions/src/post_type_supports/restore/action.tsx @@ -2,16 +2,15 @@ import { backup } from '@wordpress/icons'; import { store as coreStore } from '@wordpress/core-data'; import { __, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; -import type { Action } from '@wordpress/dataviews'; import { getItemTitle, isTemplateOrTemplatePart } from '../../shared/utils'; -import type { CoreDataError, PostWithPermissions } from '../../shared/types'; +import type { + CoreDataError, + PostWithPermissions, + ActionScriptParts, +} from '../../shared/types'; -const restorePost: Action< PostWithPermissions > = { - id: 'restore', - label: __( 'Restore' ), - isPrimary: true, +const restorePost: ActionScriptParts< PostWithPermissions > = { icon: backup, - supportsBulk: true, isEligible( item ) { return ( ! isTemplateOrTemplatePart( item ) && @@ -125,6 +124,7 @@ const restorePost: Action< PostWithPermissions > = { }; /** - * Restore action for PostWithPermissions. + * The JavaScript parts of the restore action for PostWithPermissions; its data + * is in `action.php`. */ export default restorePost; diff --git a/packages/core-actions/src/post_type_supports/view-post-revisions/action.tsx b/packages/core-actions/src/post_type_supports/view-post-revisions/action.tsx index 93b9c75859ce93..b7144adad3fa49 100644 --- a/packages/core-actions/src/post_type_supports/view-post-revisions/action.tsx +++ b/packages/core-actions/src/post_type_supports/view-post-revisions/action.tsx @@ -1,11 +1,8 @@ import { addQueryArgs } from '@wordpress/url'; import { __, sprintf } from '@wordpress/i18n'; -import type { Action } from '@wordpress/dataviews'; -import type { Post } from '../../shared/types'; +import type { Post, ActionScriptParts } from '../../shared/types'; -const viewPostRevisions: Action< Post > = { - id: 'view-post-revisions', - context: 'list', +const viewPostRevisions: ActionScriptParts< Post > = { label( items ) { const revisionsCount = items[ 0 ]._links?.[ 'version-history' ]?.[ 0 ]?.count ?? 0; @@ -38,6 +35,7 @@ const viewPostRevisions: Action< Post > = { }; /** - * View post revisions action for Post. + * The JavaScript parts of the view post revisions action for Post; its data is + * in `action.php`. */ export default viewPostRevisions; diff --git a/packages/core-actions/src/post_type_supports/view-post/action.tsx b/packages/core-actions/src/post_type_supports/view-post/action.tsx index 57a127792b3246..08ca3866c1cdd5 100644 --- a/packages/core-actions/src/post_type_supports/view-post/action.tsx +++ b/packages/core-actions/src/post_type_supports/view-post/action.tsx @@ -1,12 +1,7 @@ import { external } from '@wordpress/icons'; -import { _x } from '@wordpress/i18n'; -import type { Action } from '@wordpress/dataviews'; -import type { BasePost } from '../../shared/types'; +import type { BasePost, ActionScriptParts } from '../../shared/types'; -const viewPost: Action< BasePost > = { - id: 'view-post', - label: _x( 'View', 'verb' ), - isPrimary: true, +const viewPost: ActionScriptParts< BasePost > = { icon: external, isEligible( post ) { return post.status !== 'trash'; @@ -21,6 +16,7 @@ const viewPost: Action< BasePost > = { }; /** - * View post action for BasePost. + * The JavaScript parts of the view post action for BasePost; its data is in + * `action.php`. */ export default viewPost; diff --git a/packages/core-actions/src/shared/types.ts b/packages/core-actions/src/shared/types.ts index c968427bd68420..1c7c5d02a6a3ed 100644 --- a/packages/core-actions/src/shared/types.ts +++ b/packages/core-actions/src/shared/types.ts @@ -1,3 +1,5 @@ +import type { Action } from '@wordpress/dataviews'; + /** * The types of the posts the actions act on, copied from `@wordpress/fields`. */ @@ -97,3 +99,20 @@ export type PostWithPermissions = Post & { // The rejection of a core-data request: a REST error object, not an `Error`. export type CoreDataError = { message?: string; code?: string }; + +/** + * Makes the properties of each member of a union optional, without its `id`. + * `Omit` applied to the union itself would only keep the properties all its + * members share. + */ +type PartialWithoutId< T > = T extends unknown + ? Partial< Omit< T, 'id' > > + : never; + +/** + * The JavaScript parts of an action, which its script module provides: what + * PHP cannot serialize, such as `callback`, `RenderModal`, `isEligible`, or + * `icon`. The rest of the action is in the `action.php` next to its + * `action.tsx`, and the server registers it. + */ +export type ActionScriptParts< Item > = PartialWithoutId< Action< Item > >; diff --git a/packages/core-actions/src/test/post-type-supports.jsdom.test.ts b/packages/core-actions/src/test/post-type-supports.jsdom.test.ts index 623dcac1b592a2..a1c9d5412bbabd 100644 --- a/packages/core-actions/src/test/post-type-supports.jsdom.test.ts +++ b/packages/core-actions/src/test/post-type-supports.jsdom.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import postTypeSupports from '../post_type_supports'; describe( 'post_type_supports', () => { - it( 'maps the id of each default action to the action', () => { + it( 'maps the id of each default action to its JavaScript parts', () => { expect( Object.keys( postTypeSupports ) ).toEqual( [ 'delete-post', 'duplicate-post', @@ -15,8 +15,13 @@ describe( 'post_type_supports', () => { 'view-post', 'view-post-revisions', ] ); - for ( const [ id, action ] of Object.entries( postTypeSupports ) ) { - expect( action.id ).toBe( id ); + for ( const parts of Object.values( postTypeSupports ) ) { + // The data of the action, such as its id and label, is + // registered on the server. + expect( parts ).not.toHaveProperty( 'id' ); + expect( 'callback' in parts || 'RenderModal' in parts ).toBe( + true + ); } } ); diff --git a/packages/core-actions/src/test/wp-block.jsdom.test.ts b/packages/core-actions/src/test/wp-block.jsdom.test.ts index 715a470b939d78..953db42ab59800 100644 --- a/packages/core-actions/src/test/wp-block.jsdom.test.ts +++ b/packages/core-actions/src/test/wp-block.jsdom.test.ts @@ -2,13 +2,18 @@ import { describe, expect, it } from 'vitest'; import wpBlock from '../wp_block'; describe( 'wp_block', () => { - it( 'maps the id of each pattern action to the action', () => { + it( 'maps the id of each pattern action to its JavaScript parts', () => { expect( Object.keys( wpBlock ) ).toEqual( [ 'duplicate-pattern', 'export-pattern', ] ); - for ( const [ id, action ] of Object.entries( wpBlock ) ) { - expect( action.id ).toBe( id ); + for ( const parts of Object.values( wpBlock ) ) { + // The data of the action, such as its id and label, is + // registered on the server. + expect( parts ).not.toHaveProperty( 'id' ); + expect( 'callback' in parts || 'RenderModal' in parts ).toBe( + true + ); } } ); } ); diff --git a/packages/core-actions/src/test/wp-template-part.jsdom.test.ts b/packages/core-actions/src/test/wp-template-part.jsdom.test.ts index 7fb876f606f0fd..f3d12c15f3fa3f 100644 --- a/packages/core-actions/src/test/wp-template-part.jsdom.test.ts +++ b/packages/core-actions/src/test/wp-template-part.jsdom.test.ts @@ -2,12 +2,17 @@ import { describe, expect, it } from 'vitest'; import wpTemplatePart from '../wp_template_part'; describe( 'wp_template_part', () => { - it( 'maps the id of each template part action to the action', () => { + it( 'maps the id of each template part action to its JavaScript parts', () => { expect( Object.keys( wpTemplatePart ) ).toEqual( [ 'duplicate-template-part', ] ); - for ( const [ id, action ] of Object.entries( wpTemplatePart ) ) { - expect( action.id ).toBe( id ); + for ( const parts of Object.values( wpTemplatePart ) ) { + // The data of the action, such as its id and label, is + // registered on the server. + expect( parts ).not.toHaveProperty( 'id' ); + expect( 'callback' in parts || 'RenderModal' in parts ).toBe( + true + ); } } ); } ); diff --git a/packages/core-actions/src/wp_block/duplicate-pattern/action.tsx b/packages/core-actions/src/wp_block/duplicate-pattern/action.tsx index bc8b3bd2fa0245..4e09c24792a0a3 100644 --- a/packages/core-actions/src/wp_block/duplicate-pattern/action.tsx +++ b/packages/core-actions/src/wp_block/duplicate-pattern/action.tsx @@ -1,20 +1,15 @@ import { _x } from '@wordpress/i18n'; // @ts-expect-error `@wordpress/patterns` is not typed yet. import { privateApis as patternsPrivateApis } from '@wordpress/patterns'; -import type { Action } from '@wordpress/dataviews'; import { unlock } from '../../lock-unlock'; -import type { Pattern } from '../../shared/types'; +import type { Pattern, ActionScriptParts } from '../../shared/types'; // Patterns. const { CreatePatternModalContents, useDuplicatePatternProps } = unlock( patternsPrivateApis ); -const duplicatePattern: Action< Pattern > = { - id: 'duplicate-pattern', - label: _x( 'Duplicate…', 'action label' ), +const duplicatePattern: ActionScriptParts< Pattern > = { isEligible: ( item ) => item.type !== 'wp_template_part', - modalHeader: _x( 'Duplicate pattern', 'action label' ), - modalFocusOnMount: 'firstContentElement', RenderModal: ( { items, closeModal } ) => { const [ item ] = items; const duplicatedProps = useDuplicatePatternProps( { @@ -32,6 +27,7 @@ const duplicatePattern: Action< Pattern > = { }; /** - * Duplicate action for Pattern. + * The JavaScript parts of the duplicate action for Pattern; its data is in + * `action.php`. */ export default duplicatePattern; diff --git a/packages/core-actions/src/wp_block/export-pattern/action.tsx b/packages/core-actions/src/wp_block/export-pattern/action.tsx index 7823f8a571d503..c734178967ea0e 100644 --- a/packages/core-actions/src/wp_block/export-pattern/action.tsx +++ b/packages/core-actions/src/wp_block/export-pattern/action.tsx @@ -4,8 +4,7 @@ import removeAccents from 'remove-accents'; import { downloadBlob } from '@wordpress/blob'; import { __ } from '@wordpress/i18n'; import { download } from '@wordpress/icons'; -import type { Action } from '@wordpress/dataviews'; -import type { Pattern } from '../../shared/types'; +import type { Pattern, ActionScriptParts } from '../../shared/types'; import { getItemTitle } from '../../shared/utils'; function getJsonFromItem( item: Pattern ) { @@ -24,11 +23,8 @@ function getJsonFromItem( item: Pattern ) { ); } -const exportPattern: Action< Pattern > = { - id: 'export-pattern', - label: __( 'Export as JSON' ), +const exportPattern: ActionScriptParts< Pattern > = { icon: download, - supportsBulk: true, isEligible: ( item ) => item.type === 'wp_block', callback: async ( items ) => { if ( items.length === 1 ) { @@ -68,6 +64,7 @@ const exportPattern: Action< Pattern > = { }; /** - * Export action as JSON for Pattern. + * The JavaScript parts of the export action as JSON for Pattern; its data is in + * `action.php`. */ export default exportPattern; diff --git a/packages/core-actions/src/wp_block/index.ts b/packages/core-actions/src/wp_block/index.ts index 277959baefb08f..8b4b02f4654980 100644 --- a/packages/core-actions/src/wp_block/index.ts +++ b/packages/core-actions/src/wp_block/index.ts @@ -3,16 +3,17 @@ * `@wordpress/core-actions/wp_block`. * * The actions patterns have on top of the defaults. Its default export maps - * the id of each action to the action. + * the id of each action to its JavaScript parts, which the client merges into + * the action the server registers, see the `post_type_supports` module. */ -import type { Action } from '@wordpress/dataviews'; +import type { ActionScriptParts } from '../shared/types'; import duplicatePattern from './duplicate-pattern/action'; import exportPattern from './export-pattern/action'; const actions = { 'duplicate-pattern': duplicatePattern, 'export-pattern': exportPattern, -} satisfies Record< string, Action< any > >; +} satisfies Record< string, ActionScriptParts< any > >; export default actions; diff --git a/packages/core-actions/src/wp_template_part/duplicate-template-part/action.tsx b/packages/core-actions/src/wp_template_part/duplicate-template-part/action.tsx index 31d29cc55a3f62..f8bdee6f96b265 100644 --- a/packages/core-actions/src/wp_template_part/duplicate-template-part/action.tsx +++ b/packages/core-actions/src/wp_template_part/duplicate-template-part/action.tsx @@ -3,11 +3,10 @@ import { _x, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { useMemo } from '@wordpress/element'; import { parse } from '@wordpress/blocks'; -import type { Action } from '@wordpress/dataviews'; // @ts-expect-error `@wordpress/patterns` is not typed yet. import { privateApis as patternsPrivateApis } from '@wordpress/patterns'; import { unlock } from '../../lock-unlock'; -import type { Post, TemplatePart } from '../../shared/types'; +import type { Post, TemplatePart, ActionScriptParts } from '../../shared/types'; import { getItemTitle } from '../../shared/utils'; // Patterns. @@ -16,12 +15,8 @@ const { CreateTemplatePartModalContents } = unlock( patternsPrivateApis ); /** * This action is used to duplicate a template part. */ -const duplicateTemplatePart: Action< TemplatePart > = { - id: 'duplicate-template-part', - label: _x( 'Duplicate…', 'action label' ), +const duplicateTemplatePart: ActionScriptParts< TemplatePart > = { isEligible: ( item ) => item.type === 'wp_template_part', - modalHeader: _x( 'Duplicate template part', 'action label' ), - modalFocusOnMount: 'firstContentElement', RenderModal: ( { items, closeModal } ) => { const [ item ] = items; const blocks = useMemo( () => { @@ -67,6 +62,7 @@ const duplicateTemplatePart: Action< TemplatePart > = { }, }; /** - * Duplicate action for TemplatePart. + * The JavaScript parts of the duplicate action for TemplatePart; its data is in + * `action.php`. */ export default duplicateTemplatePart; diff --git a/packages/core-actions/src/wp_template_part/index.ts b/packages/core-actions/src/wp_template_part/index.ts index e5c7e5e1483eab..f06e4157c240cb 100644 --- a/packages/core-actions/src/wp_template_part/index.ts +++ b/packages/core-actions/src/wp_template_part/index.ts @@ -3,14 +3,15 @@ * `@wordpress/core-actions/wp_template_part`. * * The actions template parts have on top of the defaults. Its default export - * maps the id of each action to the action. + * maps the id of each action to its JavaScript parts, which the client merges + * into the action the server registers, see the `post_type_supports` module. */ -import type { Action } from '@wordpress/dataviews'; +import type { ActionScriptParts } from '../shared/types'; import duplicateTemplatePart from './duplicate-template-part/action'; const actions = { 'duplicate-template-part': duplicateTemplatePart, -} satisfies Record< string, Action< any > >; +} satisfies Record< string, ActionScriptParts< any > >; export default actions; From bf374c93cda7cbdad85a021a30a73fe11a864bc8 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 14 Oct 2026 01:58:05 +0100 Subject: [PATCH 08/20] E2E: Replace and remove a core action The test plugin replaces the core `move-to-trash` action with a new label and the script module of the core action, which keeps its eligibility and its confirmation, and removes `duplicate-post`. --- .../e2e-tests/plugins/entity-actions-api.php | 43 +++++++++++- .../site-editor/entity-actions-api.spec.js | 66 ++++++++++++++++++- 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/packages/e2e-tests/plugins/entity-actions-api.php b/packages/e2e-tests/plugins/entity-actions-api.php index 48b935de68aaff..7dfbe992a11499 100644 --- a/packages/e2e-tests/plugins/entity-actions-api.php +++ b/packages/e2e-tests/plugins/entity-actions-api.php @@ -7,7 +7,7 @@ * Extends the actions of Pages through the entity actions API, one self-contained * function per case. Every case registers its actions on * `wp_entity_actions_api_init`, the action the registry fires the first time its - * actions are read, after `init`. + * actions are read, after `init`, once the core actions are registered. * * 1. `add_action_with_script_module`: an action * (`gutenberg-test-entity-actions-api/close-comments`) whose data is registered @@ -16,6 +16,10 @@ * `gutenberg-test-entity-actions-api/archive` action no registration names: the * module augments the actions it was registered with, so the entry is * ignored and the action is not shown. + * 2. `replace_action`: a substitute for a core action (`move-to-trash`), + * unregistered and registered again with a new label, and with the script + * module of the core action, which keeps its JavaScript parts. + * 3. `remove_action`: the removal of a core action (`duplicate-post`). * * @package gutenberg-test-entity-actions-api */ @@ -57,3 +61,40 @@ function gutenberg_test_entity_actions_api_add_action_with_script_module() { ); } add_action( 'wp_entity_actions_api_init', 'gutenberg_test_entity_actions_api_add_action_with_script_module' ); + +/** + * Case 2: a substitute for a core action (`move-to-trash`). Unregistering + * drops its definition and detaches its script module, so the registration + * that follows is the whole action: a new label, and not primary, so it moves + * from the row to the actions menu. Registered with the script module of the + * core action, it keeps its eligibility and its confirmation modal. The + * default priority runs after the core actions: there is nothing to + * unregister before. + */ +function gutenberg_test_entity_actions_api_replace_action() { + wp_unregister_entity_actions( 'postType', 'page', array( 'move-to-trash' ) ); + wp_register_entity_actions( + 'gutenberg-test-entity-actions-api', + 'postType', + 'page', + array( + array( + 'id' => 'move-to-trash', + 'label' => 'Move to bin…', + 'supportsBulk' => true, + 'hideModalHeader' => true, + 'modalFocusOnMount' => 'firstContentElement', + ), + ), + '@wordpress/core-actions/post_type_supports' + ); +} +add_action( 'wp_entity_actions_api_init', 'gutenberg_test_entity_actions_api_replace_action' ); + +/** + * Case 3: the removal of a core action (`duplicate-post`). + */ +function gutenberg_test_entity_actions_api_remove_action() { + wp_unregister_entity_actions( 'postType', 'page', array( 'duplicate-post' ) ); +} +add_action( 'wp_entity_actions_api_init', 'gutenberg_test_entity_actions_api_remove_action' ); diff --git a/test/e2e/specs/site-editor/entity-actions-api.spec.js b/test/e2e/specs/site-editor/entity-actions-api.spec.js index a28b269e4b8a1e..b58b5449dd7a67 100644 --- a/test/e2e/specs/site-editor/entity-actions-api.spec.js +++ b/test/e2e/specs/site-editor/entity-actions-api.spec.js @@ -135,6 +135,64 @@ test.describe( 'Entity actions API', () => { ).toHaveCount( 0 ); } ); + test( 'replaces a core action, keeping its JavaScript parts', async ( { + admin, + page, + requestUtils, + } ) => { + const binPage = await createPage( requestUtils, { + title: 'Bin Page', + } ); + await admin.visitSiteEditor( { postType: 'page' } ); + await openRowActions( page, 'Bin Page' ); + + // The core action is gone, under its label and as a primary action. + await expect( + page.getByRole( 'menuitem', { name: 'Trash…' } ) + ).toHaveCount( 0 ); + await expect( + page + .getByRole( 'row', { name: /Bin Page/ } ) + .getByRole( 'button', { name: 'Trash…' } ) + ).toHaveCount( 0 ); + + // The substitute opens the confirmation of the core action. + await page.getByRole( 'menuitem', { name: 'Move to bin…' } ).click(); + const dialog = page.getByRole( 'dialog' ); + await expect( dialog ).toContainText( + 'Are you sure you want to move "Bin Page" to the trash?' + ); + await dialog.getByRole( 'button', { name: 'Trash' } ).click(); + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: '"Bin Page" moved to the trash.' } ) + ).toBeVisible(); + await expect + .poll( + async () => + ( + await requestUtils.rest( { + path: `/wp/v2/pages/${ binPage.id }`, + } ) + ).status + ) + .toBe( 'trash' ); + } ); + + test( 'removes a core action', async ( { admin, page, requestUtils } ) => { + await createPage( requestUtils, { title: 'Single Page' } ); + await admin.visitSiteEditor( { postType: 'page' } ); + await openRowActions( page, 'Single Page' ); + + await expect( + page.getByRole( 'menuitem', { name: 'Rename…' } ) + ).toBeVisible(); + await expect( + page.getByRole( 'menuitem', { name: 'Duplicate…' } ) + ).toHaveCount( 0 ); + } ); + test( 'shows the actions in the editor inspector', async ( { admin, editor, @@ -152,12 +210,14 @@ test.describe( 'Entity actions API', () => { .getByRole( 'button', { name: 'Actions' } ) .click(); - // The core actions show along with the action registered on the server. await expect( - page.getByRole( 'menuitem', { name: 'Duplicate…' } ) + page.getByRole( 'menuitem', { name: 'Close comments' } ) ).toBeVisible(); await expect( - page.getByRole( 'menuitem', { name: 'Close comments' } ) + page.getByRole( 'menuitem', { name: 'Move to bin…' } ) ).toBeVisible(); + await expect( + page.getByRole( 'menuitem', { name: 'Duplicate…' } ) + ).toHaveCount( 0 ); } ); } ); From ededfb42e574f04325e7305e89980f95596b0f37 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 14 Oct 2026 03:22:46 +0100 Subject: [PATCH 09/20] Editor: Show a notice when the actions of an entity cannot be loaded Every action of a post type now comes from the server, so when the request fails, a list or the inspector has no action at all, with only a warning in the console to tell why. Like `useFields()` of the fields loader, `useEntityActions()` now returns the error along with the actions, with the message of the server when there is one, and copy of our own otherwise. `usePostActions()` shows it in a snackbar that stays until it is dismissed, since the page has to be reloaded to get the actions. --- packages/editor/CHANGELOG.md | 2 +- .../src/components/post-actions/actions.jsx | 21 ++++++- .../src/dataviews/entity-actions-loader.ts | 60 ++++++++++++++----- .../test/entity-actions-loader.jsdom.test.tsx | 36 ++++++++--- 4 files changed, 90 insertions(+), 29 deletions(-) diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index c19e660db44e87..3e7159068a60cb 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -6,7 +6,7 @@ - Raise the minimum required Node.js version to `>=22.13.0`, for stable `require(esm)` support ([#84313](https://github.com/WordPress/gutenberg/pull/84313)). - The editor stylesheet no longer includes the styles of `@wordpress/fields`. A plugin that renders `MediaEdit` loads `@wordpress/fields/build-style/style.css` with its own script, as the package README describes ([#84897](https://github.com/WordPress/gutenberg/pull/84897)). -- Every action of a post type now comes from the server: `usePostActions` shows the actions the `wp/v2/entity-actions` route returns, merged with the JavaScript parts of the script modules registered along with them, and `registerPostTypeSchema()` registers no action. +- Every action of a post type now comes from the server: `usePostActions` shows the actions the `wp/v2/entity-actions` route returns, merged with the JavaScript parts of the script modules registered along with them, and `registerPostTypeSchema()` registers no action. When the actions cannot be loaded, a notice says so. - `registerEntityAction` and `unregisterEntityAction`, only available in the Gutenberg plugin, have no effect. They still warn, and they no longer register or unregister anything: every action of an entity is registered on the server. Register and unregister actions in PHP on the `wp_entity_actions_api_init` action instead, with a script module for their JavaScript parts. ### New Features diff --git a/packages/editor/src/components/post-actions/actions.jsx b/packages/editor/src/components/post-actions/actions.jsx index 06e10134a5a444..882da8a92e98a8 100644 --- a/packages/editor/src/components/post-actions/actions.jsx +++ b/packages/editor/src/components/post-actions/actions.jsx @@ -1,16 +1,31 @@ -import { useSelect } from '@wordpress/data'; -import { useMemo } from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { useEffect, useMemo } from '@wordpress/element'; import { store as coreStore } from '@wordpress/core-data'; +import { store as noticesStore } from '@wordpress/notices'; import { useEntityActions } from '../../dataviews/entity-actions-loader'; import { useSetAsHomepageAction } from './set-as-homepage'; import { useSetAsPostsPageAction } from './set-as-posts-page'; export function usePostActions( { postType, onActionPerformed, context } ) { - const defaultActions = useEntityActions( { + const { actions: defaultActions, error } = useEntityActions( { kind: 'postType', name: postType, } ); + // Without its actions, a screen has no way to act on its items, so the + // notice stays until it is dismissed. Its id keeps a single notice per + // post type, however many components show the actions. + const { createErrorNotice } = useDispatch( noticesStore ); + useEffect( () => { + if ( error ) { + createErrorNotice( error.message, { + type: 'snackbar', + id: `editor-actions-${ postType }`, + explicitDismiss: true, + } ); + } + }, [ createErrorNotice, error, postType ] ); + const shouldShowHomepageActions = useSelect( ( select ) => { if ( postType !== 'page' ) { diff --git a/packages/editor/src/dataviews/entity-actions-loader.ts b/packages/editor/src/dataviews/entity-actions-loader.ts index a7bf7d4d61dbb1..81f1b6e9a23afa 100644 --- a/packages/editor/src/dataviews/entity-actions-loader.ts +++ b/packages/editor/src/dataviews/entity-actions-loader.ts @@ -9,7 +9,8 @@ import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; -import { useEffect, useState } from '@wordpress/element'; +import { useEffect, useMemo, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; import type { Action } from '@wordpress/dataviews'; import { unlock } from '../lock-unlock'; @@ -49,6 +50,32 @@ function importModule( id: string ) { return import( /* webpackIgnore: true */ /* @vite-ignore */ id ); } +/** + * Turns the rejection of the actions request into an `Error` to show. + * + * A failed `apiFetch` rejects with a plain `{ code, message, data }` object + * rather than an `Error`, so the server's own message is used when there is + * one. Anything else, such as a `Response` from `parse: false` or a rejection + * from a custom fetch handler, falls back to copy of our own, so there is + * always something to show. + * + * @param value The rejection. + * @return An `Error` whose `cause` is the original rejection. + */ +function toError( value: unknown ): Error { + if ( value instanceof Error ) { + return value; + } + const serverMessage = ( value as { message?: unknown } )?.message; + const message = + typeof serverMessage === 'string' && serverMessage.trim() + ? serverMessage + : __( + 'The actions of this screen did not load. Reload the page to try again.' + ); + return new Error( message, { cause: value } ); +} + /** * Imports the script modules of an actions config and merges their * JavaScript parts into the actions. @@ -155,8 +182,8 @@ function getResolvedActions< Item >( * @param entity The entity. * @param entity.kind Entity kind (e.g. `postType`). * @param entity.name Entity name (e.g. `page`). - * @return The actions, in the server order. Empty until they load, or when - * they cannot be requested, which is reported. + * @return The actions, in the server order (empty until they load), and the + * error when they could not be requested. */ export function useEntityActions< Item = any >( { kind, @@ -164,7 +191,10 @@ export function useEntityActions< Item = any >( { }: { kind: string; name: string; -} ): Action< Item >[] { +} ): { + actions: Action< Item >[]; + error?: Error; +} { const { config, error } = useSelect( ( select ) => { const { getEntityActionsConfig, getResolutionError } = unlock( @@ -182,21 +212,16 @@ export function useEntityActions< Item = any >( { [ kind, name ] ); - useEffect( () => { - if ( error ) { - // eslint-disable-next-line no-console - console.warn( - `Could not load the actions of ${ kind }/${ name }.`, - error - ); - } - }, [ error, kind, name ] ); - const [ resolved, setResolved ] = useState< { config: EntityActionsConfig; actions: Action< Item >[]; } >(); + const normalizedError = useMemo( + () => ( error ? toError( error ) : undefined ), + [ error ] + ); + useEffect( () => { if ( ! config ) { return; @@ -212,8 +237,11 @@ export function useEntityActions< Item = any >( { }; }, [ config ] ); + if ( normalizedError ) { + return { actions: EMPTY_ACTIONS, error: normalizedError }; + } if ( ! config || resolved?.config !== config ) { - return EMPTY_ACTIONS; + return { actions: EMPTY_ACTIONS }; } - return resolved.actions; + return { actions: resolved.actions }; } diff --git a/packages/editor/src/dataviews/test/entity-actions-loader.jsdom.test.tsx b/packages/editor/src/dataviews/test/entity-actions-loader.jsdom.test.tsx index e25dbd225ac5bb..ad9b71bc93ea66 100644 --- a/packages/editor/src/dataviews/test/entity-actions-loader.jsdom.test.tsx +++ b/packages/editor/src/dataviews/test/entity-actions-loader.jsdom.test.tsx @@ -223,9 +223,12 @@ describe( 'useEntityActions', () => { const { result } = renderUseActions(); - expect( result.current ).toEqual( [] ); - await waitFor( () => expect( result.current ).toHaveLength( 1 ) ); - expect( result.current[ 0 ] ).toEqual( { + expect( result.current ).toEqual( { actions: [] } ); + await waitFor( () => + expect( result.current.actions ).toHaveLength( 1 ) + ); + expect( result.current.error ).toBeUndefined(); + expect( result.current.actions[ 0 ] ).toEqual( { id: 'acme/feature', label: 'Feature', supportsBulk: true, @@ -236,7 +239,7 @@ describe( 'useEntityActions', () => { } ); } ); - it( 'returns no actions and reports it when the request fails', async () => { + it( 'returns no actions and the message of the server when the request fails', async () => { const error = { code: 'rest_forbidden', message: 'Sorry, you are not allowed to do that.', @@ -247,11 +250,26 @@ describe( 'useEntityActions', () => { const { result } = renderUseActions(); await waitFor( () => - expect( console ).toHaveWarnedWith( - 'Could not load the actions of postType/page.', - error - ) + expect( result.current.error ).toBeInstanceOf( Error ) + ); + expect( result.current.actions ).toEqual( [] ); + expect( result.current.error?.message ).toBe( + 'Sorry, you are not allowed to do that.' + ); + expect( result.current.error?.cause ).toBe( error ); + } ); + + it( 'returns copy of its own when the request fails without a message', async () => { + // Like the `Response` a request with `parse: false` rejects with. + vi.mocked( apiFetch ).mockRejectedValue( { status: 500 } ); + + const { result } = renderUseActions(); + + await waitFor( () => + expect( result.current.error ).toBeInstanceOf( Error ) + ); + expect( result.current.error?.message ).toBe( + 'The actions of this screen did not load. Reload the page to try again.' ); - expect( result.current ).toEqual( [] ); } ); } ); From cae2e7be23d933fc6f8ebd7a207c8fb6d29c8443 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 14 Oct 2026 03:23:08 +0100 Subject: [PATCH 10/20] Core Actions: Unhook the core actions WordPress registers The plugin registers its copy of the core actions as `gutenberg_register_core_entity_actions()`. Once WordPress ships `register_core_entity_actions()`, hooked at the same priority and before any plugin, its callback would register the core actions first, and every registration of the plugin's copy would be skipped as already registered, with a notice for each. The plugin now unhooks it, as it does for the other core callbacks it replaces. --- lib/load.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/load.php b/lib/load.php index 5aeb368df5456c..f1da94c8083c9f 100644 --- a/lib/load.php +++ b/lib/load.php @@ -228,6 +228,9 @@ function gutenberg_get_experiment_default( $name ) { } if ( is_dir( __DIR__ . '/../build/scripts/core-actions' ) ) { require_once __DIR__ . '/../build/scripts/core-actions/index.php'; + // The plugin registers its copy of the core actions instead of the one + // WordPress ships, which would otherwise register them first. + remove_action( 'wp_entity_actions_api_init', 'register_core_entity_actions', 0 ); } // Block supports overrides. From 8e12f68bc9c15a7681e5fdb314658569ef334923 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 14 Oct 2026 03:30:53 +0100 Subject: [PATCH 11/20] Core Actions: Keep the core definition when replacing an action The example of the README and the e2e test plugin replaced a core action with a definition of their own, so every property they did not repeat was lost, and the action came back for the users WordPress does not register it for. Both now merge their changes into the definition `wp_unregister_entity_actions()` returns, and only register it again when something was unregistered. The README also says that an action registered again moves to the end of the list, and that a property of the script module, such as the label of `view-post-revisions`, takes precedence over the one registered in PHP. --- packages/core-actions/README.md | 16 +++++---- .../e2e-tests/plugins/entity-actions-api.php | 34 +++++++++++-------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/packages/core-actions/README.md b/packages/core-actions/README.md index 18e96d14bf157a..4df28c177c6ba9 100644 --- a/packages/core-actions/README.md +++ b/packages/core-actions/README.md @@ -53,20 +53,22 @@ const { default: actions } = await import( const renamePost = { ...actionFromTheServer, ...actions[ 'rename-post' ] }; ``` -A plugin replacing a core action registers its own definition with the script module of the core action, so the action keeps its JavaScript parts: +A plugin replacing a core action unregisters it, then registers it again with its changes and with the script module of the core action, so the action keeps its JavaScript parts. Merging the changes into the definition `wp_unregister_entity_actions()` returns keeps the rest of the core action, such as its modal options, and registering only when something was unregistered keeps the action away from the users WordPress does not register it for: ```php add_action( 'wp_entity_actions_api_init', function () { - wp_unregister_entity_actions( 'postType', 'page', array( 'move-to-trash' ) ); + $removed = wp_unregister_entity_actions( 'postType', 'page', array( 'move-to-trash' ) ); + if ( ! $removed ) { + return; + } wp_register_entity_actions( 'acme', 'postType', 'page', array( - array( - 'id' => 'move-to-trash', - 'label' => __( 'Move to the bin…', 'acme' ), - 'supportsBulk' => true, + array_merge( + $removed[0], + array( 'label' => __( 'Move to the bin…', 'acme' ) ) ), ), '@wordpress/core-actions/post_type_supports' @@ -74,6 +76,8 @@ add_action( 'wp_entity_actions_api_init', function () { } ); ``` +The actions are shown in registration order, so an action registered again moves to the end of the actions of the entity. A property the script module provides takes precedence over the one registered in PHP: the label of `view-post-revisions`, which shows the number of revisions, comes from its module. + ## Contributing to this package This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. diff --git a/packages/e2e-tests/plugins/entity-actions-api.php b/packages/e2e-tests/plugins/entity-actions-api.php index 7dfbe992a11499..e36d1596f3a254 100644 --- a/packages/e2e-tests/plugins/entity-actions-api.php +++ b/packages/e2e-tests/plugins/entity-actions-api.php @@ -17,8 +17,9 @@ * module augments the actions it was registered with, so the entry is * ignored and the action is not shown. * 2. `replace_action`: a substitute for a core action (`move-to-trash`), - * unregistered and registered again with a new label, and with the script - * module of the core action, which keeps its JavaScript parts. + * unregistered and registered again with a new label, merged into the core + * definition, and with the script module of the core action, which keeps + * its JavaScript parts. * 3. `remove_action`: the removal of a core action (`duplicate-post`). * * @package gutenberg-test-entity-actions-api @@ -65,25 +66,30 @@ function gutenberg_test_entity_actions_api_add_action_with_script_module() { /** * Case 2: a substitute for a core action (`move-to-trash`). Unregistering * drops its definition and detaches its script module, so the registration - * that follows is the whole action: a new label, and not primary, so it moves - * from the row to the actions menu. Registered with the script module of the - * core action, it keeps its eligibility and its confirmation modal. The - * default priority runs after the core actions: there is nothing to - * unregister before. + * that follows is the whole action: the core definition it returns, with a new + * label, and not primary, so it moves from the row to the actions menu. + * Registered with the script module of the core action, it keeps its + * eligibility and its confirmation modal. It is only registered again when it + * was registered, as WordPress registers some actions only for the users who + * can perform them. The default priority runs after the core actions: there is + * nothing to unregister before. */ function gutenberg_test_entity_actions_api_replace_action() { - wp_unregister_entity_actions( 'postType', 'page', array( 'move-to-trash' ) ); + $removed = wp_unregister_entity_actions( 'postType', 'page', array( 'move-to-trash' ) ); + if ( ! $removed ) { + return; + } wp_register_entity_actions( 'gutenberg-test-entity-actions-api', 'postType', 'page', array( - array( - 'id' => 'move-to-trash', - 'label' => 'Move to bin…', - 'supportsBulk' => true, - 'hideModalHeader' => true, - 'modalFocusOnMount' => 'firstContentElement', + array_merge( + $removed[0], + array( + 'label' => 'Move to bin…', + 'isPrimary' => false, + ) ), ), '@wordpress/core-actions/post_type_supports' From d423bda62e4538fc2d98e7f3fe262d91830c5d92 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 14 Oct 2026 03:31:34 +0100 Subject: [PATCH 12/20] Editor: Update the references to where the actions live The actions no longer live in the `core/editor` store, so the comment of the fields API that says so is updated. The list of the private actions of the store also named `registerPostTypeActions`, which is `registerPostTypeSchema`. --- docs/private-apis.md | 2 +- lib/compat/wordpress-7.2/entity-fields-api.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/private-apis.md b/docs/private-apis.md index 417689acfc2bac..55e8e8cb9aed8d 100644 --- a/docs/private-apis.md +++ b/docs/private-apis.md @@ -291,7 +291,7 @@ Private exports: Private actions: - `createTemplate` - `hideBlockTypes` -- `registerPostTypeActions` +- `registerPostTypeSchema` - `removeTemplates` - `revertTemplate` - `saveDirtyEntities` diff --git a/lib/compat/wordpress-7.2/entity-fields-api.php b/lib/compat/wordpress-7.2/entity-fields-api.php index f6d029ae8b84a6..b786d9edc4ec6f 100644 --- a/lib/compat/wordpress-7.2/entity-fields-api.php +++ b/lib/compat/wordpress-7.2/entity-fields-api.php @@ -181,7 +181,7 @@ function _wp_add_entity_field_modules_to_editor_script( $scripts = null ) { $scripts = wp_scripts(); } - // Field registration as well as actions live in packages/editor/src/dataviews/store/private-actions.ts + // The fields are loaded in packages/editor/src/dataviews/store/private-resolvers.ts // which means any screen that wants to use this mechanism needs to load the editor script. if ( ! $scripts->query( 'wp-editor', 'enqueued' ) && ! $scripts->query( 'wp-editor', 'done' ) ) { return; From 1305ec26ee03a1fab0005a8d79c9640d9e817133 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 14 Oct 2026 04:00:46 +0100 Subject: [PATCH 13/20] Add the pull request link to the changelog entries --- packages/editor/CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 3e7159068a60cb..3467b57666f920 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -6,8 +6,8 @@ - Raise the minimum required Node.js version to `>=22.13.0`, for stable `require(esm)` support ([#84313](https://github.com/WordPress/gutenberg/pull/84313)). - The editor stylesheet no longer includes the styles of `@wordpress/fields`. A plugin that renders `MediaEdit` loads `@wordpress/fields/build-style/style.css` with its own script, as the package README describes ([#84897](https://github.com/WordPress/gutenberg/pull/84897)). -- Every action of a post type now comes from the server: `usePostActions` shows the actions the `wp/v2/entity-actions` route returns, merged with the JavaScript parts of the script modules registered along with them, and `registerPostTypeSchema()` registers no action. When the actions cannot be loaded, a notice says so. -- `registerEntityAction` and `unregisterEntityAction`, only available in the Gutenberg plugin, have no effect. They still warn, and they no longer register or unregister anything: every action of an entity is registered on the server. Register and unregister actions in PHP on the `wp_entity_actions_api_init` action instead, with a script module for their JavaScript parts. +- Every action of a post type now comes from the server: `usePostActions` shows the actions the `wp/v2/entity-actions` route returns, merged with the JavaScript parts of the script modules registered along with them, and `registerPostTypeSchema()` registers no action. When the actions cannot be loaded, a notice says so ([#84660](https://github.com/WordPress/gutenberg/pull/84660)). +- `registerEntityAction` and `unregisterEntityAction`, only available in the Gutenberg plugin, have no effect. They still warn, and they no longer register or unregister anything: every action of an entity is registered on the server. Register and unregister actions in PHP on the `wp_entity_actions_api_init` action instead, with a script module for their JavaScript parts ([#84660](https://github.com/WordPress/gutenberg/pull/84660)). ### New Features @@ -22,7 +22,7 @@ ### Deprecations - `registerEntityField` and `unregisterEntityField`, which only had an effect in the Gutenberg plugin, are deprecated and have no effect anymore: the fields of an entity are registered on the server. Register and unregister them in PHP, with `wp_register_entity_fields()` and `wp_unregister_entity_fields()` on the `wp_entity_fields_api_init` action, and a script module for their JavaScript parts ([#84700](https://github.com/WordPress/gutenberg/pull/84700)). -- `registerEntityAction` and `unregisterEntityAction`, only available in the Gutenberg plugin, are deprecated. Register and unregister actions in PHP on the `wp_entity_actions_api_init` action instead, with a script module for their JavaScript parts. +- `registerEntityAction` and `unregisterEntityAction`, only available in the Gutenberg plugin, are deprecated. Register and unregister actions in PHP on the `wp_entity_actions_api_init` action instead, with a script module for their JavaScript parts ([#84660](https://github.com/WordPress/gutenberg/pull/84660)). ### Bug Fixes @@ -40,7 +40,7 @@ - Move footer-specific block breadcrumb layout styles into the package ([#84295](https://github.com/WordPress/gutenberg/pull/84295)). - Import the core actions `registerPostTypeSchema()` registers from the `@wordpress/core-actions` script modules instead of `@wordpress/fields` ([#84643](https://github.com/WordPress/gutenberg/pull/84643)). - Take `CreateTemplatePartModal` from the private APIs of `@wordpress/patterns` instead of `@wordpress/fields`, and remove it from the private APIs of the editor ([#84643](https://github.com/WordPress/gutenberg/pull/84643)). -- The `core/editor` store no longer holds the actions of an entity: its private `registerEntityAction`, `unregisterEntityAction` and `getEntityActions` are gone. +- The `core/editor` store no longer holds the actions of an entity: its private `registerEntityAction`, `unregisterEntityAction` and `getEntityActions` are gone ([#84660](https://github.com/WordPress/gutenberg/pull/84660)). ## 15.2.0 (2026-10-07) From d9557c3d54bc4192857561f24ba446d18c1f7552 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 14 Oct 2026 13:34:57 +0100 Subject: [PATCH 14/20] Core Data: Type the actions configs like the view configs The configs are kept by kind, then by name, like the view configs, and each one is the response of the route. Typing them the same way keeps the declaration on one line. --- packages/core-data/src/selectors.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 8dcea4dcc9c4f8..6948b15120e8cf 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -51,10 +51,7 @@ export interface State { collaborationSupported: boolean; viewConfigs: Record< string, Record< string, any > >; fieldsConfigs: Record< string, Record< string, FieldsConfig > >; - entityActionsConfigs: Record< - string, - Record< string, Record< string, any > > - >; + entityActionsConfigs: Record< string, Record< string, any > >; } type EntityRecordKey = string | number; From 8d6e2c34666c6394735f8e8f9a8001250862754d Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 14 Oct 2026 13:35:12 +0100 Subject: [PATCH 15/20] Editor: Keep the order of the element imports of usePostActions usePostActions() still imports useMemo and useEffect, so the line can stay as it is. --- packages/editor/src/components/post-actions/actions.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/components/post-actions/actions.jsx b/packages/editor/src/components/post-actions/actions.jsx index 882da8a92e98a8..2bccb25362e5b3 100644 --- a/packages/editor/src/components/post-actions/actions.jsx +++ b/packages/editor/src/components/post-actions/actions.jsx @@ -1,5 +1,5 @@ import { useDispatch, useSelect } from '@wordpress/data'; -import { useEffect, useMemo } from '@wordpress/element'; +import { useMemo, useEffect } from '@wordpress/element'; import { store as coreStore } from '@wordpress/core-data'; import { store as noticesStore } from '@wordpress/notices'; import { useEntityActions } from '../../dataviews/entity-actions-loader'; From 5940a74bb804f50c3d762c2ea6c9ed4c76da4120 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 14 Oct 2026 13:35:27 +0100 Subject: [PATCH 16/20] Editor: Drop the changelog entry of the actions shown after the others Every action of a post type now comes from the server, so no action is shown after the others. The breaking changes already say where the actions come from and where to register them. --- packages/editor/CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 3467b57666f920..f91d5e0d85065d 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -9,10 +9,6 @@ - Every action of a post type now comes from the server: `usePostActions` shows the actions the `wp/v2/entity-actions` route returns, merged with the JavaScript parts of the script modules registered along with them, and `registerPostTypeSchema()` registers no action. When the actions cannot be loaded, a notice says so ([#84660](https://github.com/WordPress/gutenberg/pull/84660)). - `registerEntityAction` and `unregisterEntityAction`, only available in the Gutenberg plugin, have no effect. They still warn, and they no longer register or unregister anything: every action of an entity is registered on the server. Register and unregister actions in PHP on the `wp_entity_actions_api_init` action instead, with a script module for their JavaScript parts ([#84660](https://github.com/WordPress/gutenberg/pull/84660)). -### New Features - -- Show the actions registered on the server for a post type after its other actions, in the lists of the site editor and in the inspector. Plugins register them in PHP on the `wp_entity_actions_api_init` action, with a script module for their JavaScript parts ([#84660](https://github.com/WordPress/gutenberg/pull/84660)). - ### Enhancements - The post summary of the DataForm-based inspector, the media editor, and the private `usePostFields` hook, through which the site editor screens get their fields, read the fields of the post type from the server, through the Entity fields API. The media editor also lays out the attachment fields with the form of the attachment view configuration ([#84700](https://github.com/WordPress/gutenberg/pull/84700)). From f0e70af440e4c32c0d5d9ee60b7796cab30238ab Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 16 Oct 2026 01:18:49 +0100 Subject: [PATCH 17/20] Core Actions: Make the package private The editor no longer imports the script modules of the core actions: it reads the actions from the server, which lists the modules to import. No package depends on `@wordpress/core-actions` anymore, so it does not need to be published, like `@wordpress/core-fields`. The docs manifest drops its README, as it does for the other private packages. --- docs/manifest.json | 6 ------ packages/core-actions/README.md | 10 ---------- packages/core-actions/package.json | 1 + 3 files changed, 1 insertion(+), 16 deletions(-) diff --git a/docs/manifest.json b/docs/manifest.json index dfa145c7b21382..d6d8cc988433f7 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -2327,12 +2327,6 @@ "markdown_source": "../packages/core-abilities/README.md", "parent": "packages" }, - { - "title": "@wordpress/core-actions", - "slug": "packages-core-actions", - "markdown_source": "../packages/core-actions/README.md", - "parent": "packages" - }, { "title": "@wordpress/core-commands", "slug": "packages-core-commands", diff --git a/packages/core-actions/README.md b/packages/core-actions/README.md index 4df28c177c6ba9..2e503312535c8a 100644 --- a/packages/core-actions/README.md +++ b/packages/core-actions/README.md @@ -32,16 +32,6 @@ The actions are registered in that order, which is the order they are shown in, The modules use the `wp.*` globals the `wp-editor` script loads, such as `wp.data` and `wp.components`, so they only run on pages that load the editor script. They are dynamic module dependencies of `wp-editor`, which adds them to the import map of every page loading it. -## Installation - -Install the module: - -```bash -npm install @wordpress/core-actions --save -``` - -_This package assumes that your code will run in an ES2015+ environment. If you're using an environment that has limited or no support for such language features and/or APIs, you should include the polyfill shipped in `@wordpress/babel-preset-default` in your code._ - ## Usage The package provides the `@wordpress/core-actions/post_type_supports`, `@wordpress/core-actions/wp_block`, and `@wordpress/core-actions/wp_template_part` script modules. Their default export maps the id of each action to the properties of the DataViews action that PHP cannot serialize, which complete the action the `/wp/v2/entity-actions` route returns: diff --git a/packages/core-actions/package.json b/packages/core-actions/package.json index e5d0d3bca7118e..09e986088ffe9b 100644 --- a/packages/core-actions/package.json +++ b/packages/core-actions/package.json @@ -1,6 +1,7 @@ { "name": "@wordpress/core-actions", "version": "0.1.0-prerelease", + "private": true, "description": "The actions WordPress core registers for its entities, shown in DataViews and in the editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", From 52fe2c8b11a7a3955e3d93e4ad8326e65e6d1e0f Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 16 Oct 2026 01:19:09 +0100 Subject: [PATCH 18/20] Build: Stop registering the script modules a script imports https://github.com/WordPress/gutenberg/pull/84643 made the generated `build/scripts.php` register the `module_dependencies` of each script's asset file, so the script modules the editor imported with literal `import()`s reached the import map in the plugin, as WordPress does for its own scripts. The editor no longer imports them: it imports the script modules the server lists for the actions, which `_wp_add_entity_action_modules_to_editor_script()` adds to the import map. Revert the change, so the plugin keeps the module dependencies WordPress registers for its scripts, as before. The other script modules the asset files list reach the import map as before too: the plugin enqueues the loaders of `@wordpress/latex-to-mathml` and `@wordpress/video-conversion`. --- packages/wp-build/CHANGELOG.md | 1 - .../wp-build/templates/script-registration.php.template | 6 ------ 2 files changed, 7 deletions(-) diff --git a/packages/wp-build/CHANGELOG.md b/packages/wp-build/CHANGELOG.md index 7300c4357a1c48..d1dd623351c447 100644 --- a/packages/wp-build/CHANGELOG.md +++ b/packages/wp-build/CHANGELOG.md @@ -14,7 +14,6 @@ ### Bug Fixes - Check that `SCRIPT_DEBUG` is defined before reading it in the generated `build/scripts.php` and `build/styles.php`, like the other generated files, so they no longer throw an `Error` when loaded without WordPress defining the constant ([#83633](https://github.com/WordPress/gutenberg/pull/83633)). -- Register the `module_dependencies` of each script's asset file in the generated `build/scripts.php`, so WordPress adds the script modules a script imports to the import map of the pages that load it, as it does for its own scripts ([#84643](https://github.com/WordPress/gutenberg/pull/84643)). ### Internal diff --git a/packages/wp-build/templates/script-registration.php.template b/packages/wp-build/templates/script-registration.php.template index 49aeea3aa7420f..d4de5d98125593 100644 --- a/packages/wp-build/templates/script-registration.php.template +++ b/packages/wp-build/templates/script-registration.php.template @@ -79,12 +79,6 @@ function {{PREFIX}}_register_package_scripts( $scripts ) { $version, true ); - - // The script modules the script imports, which WordPress adds to the - // import map of the pages loading the script. - if ( ! empty( $asset['module_dependencies'] ) ) { - $scripts->add_data( $script_data['handle'], 'module_dependencies', $asset['module_dependencies'] ); - } } } From 828d19dc9fbc8381d09a6b9462b1f3768b5910a4 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 16 Oct 2026 10:23:37 +0100 Subject: [PATCH 19/20] Add the pull request to the backport changelog of the entity actions API The PHP this pull request adds, the core actions registered on the server and the preload of the actions of the post editor, is backported with the entity actions API in https://github.com/WordPress/wordpress-develop/pull/14276. --- backport-changelog/7.2/14276.md | 1 + 1 file changed, 1 insertion(+) diff --git a/backport-changelog/7.2/14276.md b/backport-changelog/7.2/14276.md index 182845ed858845..cd32167ffd6050 100644 --- a/backport-changelog/7.2/14276.md +++ b/backport-changelog/7.2/14276.md @@ -1,3 +1,4 @@ https://github.com/WordPress/wordpress-develop/pull/14276 * https://github.com/WordPress/gutenberg/pull/84650 +* https://github.com/WordPress/gutenberg/pull/84660 From b8873c4adfa532f63a334eb07081db40ca38d917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:12:04 +0200 Subject: [PATCH 20/20] Core Data: Type the actions configs like the fields configs The `/wp/v2/entity-actions` route returns the same shape as `/wp/v2/entity-fields`, so the actions configs get an `EntityActionsConfig` interface mirroring `FieldsConfig` instead of `Record< string, any >`. Co-Authored-By: Claude Opus 5.5 --- packages/core-data/src/private-selectors.ts | 20 +++++++++++++++++++- packages/core-data/src/selectors.ts | 7 +++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/core-data/src/private-selectors.ts b/packages/core-data/src/private-selectors.ts index 6025dd447f36a9..63738c4c4ba4c4 100644 --- a/packages/core-data/src/private-selectors.ts +++ b/packages/core-data/src/private-selectors.ts @@ -370,6 +370,24 @@ export function getFieldsConfig( return state.fieldsConfigs?.[ kind ]?.[ name ]; } +/** + * The actions registered on the server for an entity, as returned by the + * `/wp/v2/entity-actions` route. + */ +export interface EntityActionsConfig { + kind: string; + name: string; + /** + * The serializable part of each action, in registration order. + */ + actions: Array< { id: string } & Record< string, any > >; + /** + * The script modules providing the JavaScript parts of the actions, in + * registration order, with the ids of the actions each provides. + */ + script_modules: Array< { id: string; actions: string[] } >; +} + /** * Returns the actions registered on the server for the given entity, as the * `/wp/v2/entity-actions` REST API route returns them: the serializable part of each @@ -385,7 +403,7 @@ export function getEntityActionsConfig( state: State, kind: string, name: string -): Record< string, any > | undefined { +): EntityActionsConfig | undefined { return state.entityActionsConfigs?.[ kind ]?.[ name ]; } diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 6948b15120e8cf..c6111778ad5127 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -18,7 +18,7 @@ import { getUserPermissionCacheKey, } from './utils'; import type * as ET from './entity-types'; -import type { FieldsConfig } from './private-selectors'; +import type { EntityActionsConfig, FieldsConfig } from './private-selectors'; import logEntityDeprecation from './utils/log-entity-deprecation'; // This is an incomplete, high-level approximation of the State type. @@ -51,7 +51,10 @@ export interface State { collaborationSupported: boolean; viewConfigs: Record< string, Record< string, any > >; fieldsConfigs: Record< string, Record< string, FieldsConfig > >; - entityActionsConfigs: Record< string, Record< string, any > >; + entityActionsConfigs: Record< + string, + Record< string, EntityActionsConfig > + >; } type EntityRecordKey = string | number;