diff --git a/package-lock.json b/package-lock.json index 9c6d856abe939a..7ab109ffdfc0a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54463,7 +54463,8 @@ "version": "3.57.0", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "^7.16.0" + "@babel/runtime": "^7.16.0", + "@wordpress/private-apis": "^0.39.0" }, "engines": { "node": ">=12" @@ -69459,7 +69460,8 @@ "@wordpress/hooks": { "version": "file:packages/hooks", "requires": { - "@babel/runtime": "^7.16.0" + "@babel/runtime": "^7.16.0", + "@wordpress/private-apis": "^0.39.0" } }, "@wordpress/html-entities": { diff --git a/packages/edit-site/src/components/page-pages/index.js b/packages/edit-site/src/components/page-pages/index.js index 8ac080c0ac97fd..eac1a45c127867 100644 --- a/packages/edit-site/src/components/page-pages/index.js +++ b/packages/edit-site/src/components/page-pages/index.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import { camelCase } from 'change-case'; + /** * WordPress dependencies */ @@ -11,6 +16,7 @@ import { privateApis as routerPrivateApis } from '@wordpress/router'; import { useSelect, useDispatch } from '@wordpress/data'; import { DataViews } from '@wordpress/dataviews'; import { privateApis as editorPrivateApis } from '@wordpress/editor'; +import { doAction, privateApis as hooksPrivateApis } from '@wordpress/hooks'; /** * Internal dependencies @@ -362,13 +368,17 @@ export default function PagePages() { ); const onActionPerformed = useCallback( ( actionId, items ) => { - if ( actionId === 'edit-post' ) { - const post = items[ 0 ]; - history.push( { - postId: post.id, - postType: post.type, - canvas: 'edit', - } ); + // Dispatch a private action corresponding to `actionId` under the + // `pagePages` namespace, if such a private action is registered. + // For instance, `pagePages.editPost`. + // + // @see packages/hooks/src/private-hooks.js + const hookName = unlock( hooksPrivateApis ).privateHooksMap.get( + `pagePages.${ camelCase( actionId ) }` + ); + + if ( hookName ) { + doAction( hookName, items, history ); } }, [ history ] diff --git a/packages/editor/src/components/post-actions/actions.js b/packages/editor/src/components/post-actions/actions.js index 763b354010bf13..30545820b51b0e 100644 --- a/packages/editor/src/components/post-actions/actions.js +++ b/packages/editor/src/components/post-actions/actions.js @@ -9,6 +9,7 @@ import { store as coreStore } from '@wordpress/core-data'; import { __, _n, sprintf, _x } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { useMemo, useState } from '@wordpress/element'; +import { doAction, privateApis } from '@wordpress/hooks'; import { Button, @@ -455,6 +456,11 @@ const renamePostAction = { createSuccessNotice( __( 'Name updated' ), { type: 'snackbar', } ); + doAction( + unlock( privateApis ).privateHooksMap.get( + 'postActions.renamePost' + ) + ); onActionPerformed?.( items ); } catch ( error ) { const errorMessage = diff --git a/packages/hooks/package.json b/packages/hooks/package.json index 99a6a875c647b3..8a4ec81ea8babf 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -26,7 +26,8 @@ "react-native": "src/index", "types": "build-types", "dependencies": { - "@babel/runtime": "^7.16.0" + "@babel/runtime": "^7.16.0", + "@wordpress/private-apis": "^0.39.0" }, "publishConfig": { "access": "public" diff --git a/packages/hooks/src/createAddHook.js b/packages/hooks/src/createAddHook.js index 1fcfcfab1a7d21..36518ae2c3f562 100644 --- a/packages/hooks/src/createAddHook.js +++ b/packages/hooks/src/createAddHook.js @@ -9,7 +9,7 @@ import validateHookName from './validateHookName.js'; * * Adds the hook to the appropriate hooks container. * - * @param {string} hookName Name of hook to add + * @param {string|symbol} hookName Name of hook to add * @param {string} namespace The unique namespace identifying the callback in the form `vendor/plugin/function`. * @param {import('.').Callback} callback Function to call when the hook is run * @param {number} [priority=10] Priority of this hook diff --git a/packages/hooks/src/index.js b/packages/hooks/src/index.js index 653a9537145d91..4225eb04c3da0f 100644 --- a/packages/hooks/src/index.js +++ b/packages/hooks/src/index.js @@ -25,7 +25,7 @@ import createHooks from './createHooks'; */ /** - * @typedef {Record & {__current: Current[]}} Store + * @typedef {Record & {__current: Current[]}} Store */ /** @@ -80,3 +80,53 @@ export { actions, filters, }; + +import { privateApis as hooksPrivateApis } from './private-apis'; +import { unlock } from './lock-unlock'; + +/** + * This will fire whenever a post is renamed, regardless of which UI we're + * accessing from. + */ +addAction( + unlock( hooksPrivateApis ).privateHooksMap.get( 'postActions.renamePost' ), + 'my/handle-rename-post', + function () { + // eslint-disable-next-line no-console + console.log( 'Post renamed in postActions.renamePost' ); + } +); + +/** + * This will fire only when accessing from PagePages + * (/wp-admin/site-editor.php?path=%2Fpage&layout=table) + */ +addAction( + unlock( hooksPrivateApis ).privateHooksMap.get( 'pagePages.renamePost' ), + 'my/handle-rename-post', + function () { + // eslint-disable-next-line no-console + console.log( 'Post renamed in pagePages.renamePost' ); + } +); + +/** + * That specificity helps to implement routing. Note that the contextual + * history object is passed via the hook. + */ +addAction( + unlock( hooksPrivateApis ).privateHooksMap.get( 'pagePages.editPost' ), + 'my/handle-edit-post', + function ( items, history ) { + const post = items[ 0 ]; + // eslint-disable-next-line no-console + console.log( `Editing post ${ post?.id }` ); + history.push( { + postId: post.id, + postType: post.type, + canvas: 'edit', + } ); + } +); + +export * from './private-apis'; diff --git a/packages/hooks/src/lock-unlock.js b/packages/hooks/src/lock-unlock.js new file mode 100644 index 00000000000000..1404ecd80b4d14 --- /dev/null +++ b/packages/hooks/src/lock-unlock.js @@ -0,0 +1,9 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; +export const { lock, unlock } = + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I know using unstable features means my theme or plugin will inevitably break in the next version of WordPress.', + '@wordpress/hooks' + ); diff --git a/packages/hooks/src/private-apis.js b/packages/hooks/src/private-apis.js new file mode 100644 index 00000000000000..39c1f1f2fa8e01 --- /dev/null +++ b/packages/hooks/src/private-apis.js @@ -0,0 +1,9 @@ +/** + * Internal dependencies + */ +import { lock } from './lock-unlock'; +import { privateHooksMap } from './private-hooks'; + +export const privateApis = {}; + +lock( privateApis, { privateHooksMap } ); diff --git a/packages/hooks/src/private-hooks.js b/packages/hooks/src/private-hooks.js new file mode 100644 index 00000000000000..276805907fae4d --- /dev/null +++ b/packages/hooks/src/private-hooks.js @@ -0,0 +1,27 @@ +// Define a list of "private hooks" that only core packages can use. This is +// implemented by producing Symbols from these hook names, the access to which +// will be mediated by the lock/unlock interface. +// +// Note that the standard Hooks API only accepts valid strings as hook names, +// but an exception will be made for Symbols on this list. +// +// @see validateHookName. +const privateHooks = [ + 'postActions.renamePost', + 'pagePages.renamePost', + 'pagePages.editPost', +]; + +// Used by consumers of the hooks API +// +// @example +// ```js +// const { privateHooksMap } = unlock( privateApis ); +// const MY_HOOK = privateHooksMap.get( 'myHook' ); +// doAction( MY_HOOK ); +// ``` +export const privateHooksMap = new Map( + privateHooks.map( ( label ) => [ label, Symbol( label ) ] ) +); + +export const privateHooksSet = new Set( privateHooksMap.values() ); diff --git a/packages/hooks/src/validateHookName.js b/packages/hooks/src/validateHookName.js index 03409384dab599..a8be567c313073 100644 --- a/packages/hooks/src/validateHookName.js +++ b/packages/hooks/src/validateHookName.js @@ -1,13 +1,24 @@ +/** + * Internal dependencies + */ + +import { privateHooksSet } from './private-hooks'; + /** * Validate a hookName string. * - * @param {string} hookName The hook name to validate. Should be a non empty string containing - * only numbers, letters, dashes, periods and underscores. Also, - * the hook name cannot begin with `__`. + * @param {string|symbol} hookName The hook name to validate. Should be a non + * empty string containing only numbers, + * letters, dashes, periods and underscores. + * Also, the hook name cannot begin with `__`. * * @return {boolean} Whether the hook name is valid. */ function validateHookName( hookName ) { + if ( 'symbol' === typeof hookName && privateHooksSet.has( hookName ) ) { + return true; + } + if ( 'string' !== typeof hookName || '' === hookName ) { // eslint-disable-next-line no-console console.error( 'The hook name must be a non-empty string.' ); diff --git a/packages/private-apis/src/implementation.js b/packages/private-apis/src/implementation.js index a31fd91ce094dd..bec8dd359d627f 100644 --- a/packages/private-apis/src/implementation.js +++ b/packages/private-apis/src/implementation.js @@ -25,6 +25,7 @@ const CORE_MODULES_USING_PRIVATE_APIS = [ '@wordpress/edit-widgets', '@wordpress/editor', '@wordpress/format-library', + '@wordpress/hooks', '@wordpress/interface', '@wordpress/patterns', '@wordpress/preferences',