diff --git a/.eslintrc.js b/.eslintrc.js index 09d97476dfe72..573af215844d6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -78,6 +78,10 @@ module.exports = { selector: 'ImportDeclaration[source.value=/^viewport$/]', message: 'Use @wordpress/viewport as import path instead.', }, + { + selector: 'ImportDeclaration[source.value=/^plugins$/]', + message: 'Use @wordpress/plugins as import path instead.', + }, { selector: 'CallExpression[callee.name="deprecated"] Property[key.name="version"][value.value=/' + majorMinorRegExp + '/]', message: 'Deprecated functions must be removed before releasing this version.', diff --git a/bin/build-plugin-zip.sh b/bin/build-plugin-zip.sh index 3cf089bf0dda7..4c92566b93927 100755 --- a/bin/build-plugin-zip.sh +++ b/bin/build-plugin-zip.sh @@ -99,7 +99,7 @@ zip -r gutenberg.zip \ blocks/library/*/*.php \ post-content.js \ $vendor_scripts \ - {blocks,components,date,editor,element,hooks,i18n,data,utils,edit-post,viewport}/build/*.{js,map} \ + {blocks,components,date,editor,element,hooks,i18n,data,utils,edit-post,viewport,plugins}/build/*.{js,map} \ {blocks,components,editor,edit-post}/build/*.css \ languages/gutenberg.pot \ languages/gutenberg-translations.php \ diff --git a/docs/extensibility.md b/docs/extensibility.md index 4f64d4348c888..00acc52910004 100644 --- a/docs/extensibility.md +++ b/docs/extensibility.md @@ -120,38 +120,8 @@ wp.hooks.addFilter( _Note:_ This filter must always be run on every page load, and not in your browser's developer tools console. Otherwise, a [block validation](https://wordpress.org/gutenberg/handbook/block-api/block-edit-save/#validation) error will occur the next time the post is edited. This is due to the fact that block validation occurs by verifying that the saved output matches what is stored in the post's content during editor initialization. So, if this filter does not exist when the editor loads, the block will be marked as invalid. -## Adding a sidebar +## Extending the Editor UI -**Warning:** This is an experimental API, and is subject to change or even removal. +Extending the editor UI can be accomplished with the `registerPlugin` API, allowing you to define all your plugin's UI elements in one place. -### Registering a sidebar - -`wp.editPost.__experimentalRegisterSidebar( name: string, settings: { title: string, render: function } )` - -This method takes a sidebar `name` and a `settings` object, containing a title and a render function. The name should contain a namespace prefix (Example: my-plugin/my-custom-sidebar). - -**Example** - -```js -wp.editPost.__experimentalRegisterSidebar( 'my-plugin/my-custom-sidebar', { - render: function mySidebar() { - return

This is an example

; - }, -} ); -``` - -### Activating a sidebar - -`wp.editPost.__experimentalActivateSidebar( name: string )` - -This method takes the `name` of the sidebar you'd like to open. That sidebar should have been registered beforehand using the `registerSidebar` method. - -**Example** - -```js -wp.editPost.__experimentalActivateSidebar( 'my-plugin/my-custom-sidebar' ); -``` - -## Extending the editor's UI (Slot and Fill) - -Coming soon. +Refer to [the plugins module documentation](../plugins/) for more information. diff --git a/edit-post/api/README.md b/edit-post/api/README.md deleted file mode 100644 index 22537eab2210d..0000000000000 --- a/edit-post/api/README.md +++ /dev/null @@ -1,37 +0,0 @@ -Edit post API -==== - -The edit post API contains the following methods: - -### `wp.editPost.__experimentalRegisterSidebar( name: string, settings: { title: string, render: function } )` - -**Warning:** This is an experimental API, and is subject to change or even removal. - -This method takes two arguments: -- a `name` to identify the sidebar. This name should contain a namespace prefix, followed by a slash and a sidebar name. The name should include only lowercase alphanumeric characters or dashes, and start with a letter. Example: `my-plugin/my-custom-sidebar`. -- a `settings` object, containing a title and a render function. - -This method only registers a sidebar. To open the sidebar, use the `__experimentalActivateSidebar` method below. - -#### Example: - -```js -wp.editPost.__experimentalRegisterSidebar( 'my-plugin/my-custom-sidebar', { - render: function mySidebar() { - return

This is an example

; - }, -} ); -``` - -### `wp.editPost.__experimentalActivateSidebar( name: string )` - -**Warning:** This is an experimental API, and is subject to change or even removal. - -This method takes one argument: -- the `name` of the sidebar you'd like to open. That sidebar should have been registered beforehand using the `__experimentalRegisterSidebar` method. - -#### Example: - -```js -wp.editPost.__experimentalActivateSidebar( 'my-plugin/my-custom-sidebar' ); -``` diff --git a/edit-post/api/index.js b/edit-post/api/index.js deleted file mode 100644 index 3284d43eacacf..0000000000000 --- a/edit-post/api/index.js +++ /dev/null @@ -1,4 +0,0 @@ -export { - registerSidebar as __experimentalRegisterSidebar, - activateSidebar as __experimentalActivateSidebar, -} from './sidebar'; diff --git a/edit-post/api/sidebar.js b/edit-post/api/sidebar.js deleted file mode 100644 index f1f5eee0890af..0000000000000 --- a/edit-post/api/sidebar.js +++ /dev/null @@ -1,100 +0,0 @@ -/* eslint no-console: [ 'error', { allow: [ 'error' ] } ] */ - -/* External dependencies */ -import { isFunction } from 'lodash'; - -/** - * WordPress dependencies - */ -import { dispatch } from '@wordpress/data'; - -/* Internal dependencies */ -import { applyFilters } from '@wordpress/hooks'; - -const sidebars = {}; - -/** - * Registers a sidebar to the editor. - * - * A button will be shown in the settings menu to open the sidebar. The sidebar - * can be manually opened by calling the `activateSidebar` function. - * - * @param {string} name The name of the sidebar. Should be in - * `[plugin]/[sidebar]` format. - * @param {Object} settings The settings for this sidebar. - * @param {string} settings.title The name to show in the settings menu. - * @param {Function} settings.render The function that renders the sidebar. - * - * @return {Object} The final sidebar settings object. - */ -export function registerSidebar( name, settings ) { - settings = { - name, - ...settings, - }; - - if ( typeof name !== 'string' ) { - console.error( - 'Sidebar names must be strings.' - ); - return null; - } - if ( ! /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test( name ) ) { - console.error( - 'Sidebar names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-sidebar.' - ); - return null; - } - if ( ! settings || ! isFunction( settings.render ) ) { - console.error( - 'The "render" property must be specified and must be a valid function.' - ); - return null; - } - if ( sidebars[ name ] ) { - console.error( - `Sidebar ${ name } is already registered.` - ); - } - - if ( ! settings.title ) { - console.error( - `The sidebar ${ name } must have a title.` - ); - return null; - } - if ( typeof settings.title !== 'string' ) { - console.error( - 'Sidebar titles must be strings.' - ); - return null; - } - - settings = applyFilters( 'editor.registerSidebar', settings, name ); - - return sidebars[ name ] = settings; -} - -/** - * Retrieves the sidebar settings object. - * - * @param {string} name The name of the sidebar to retrieve the settings for. - * - * @return {Object} The settings object of the sidebar. Or null if the - * sidebar doesn't exist. - */ -export function getSidebarSettings( name ) { - if ( ! sidebars.hasOwnProperty( name ) ) { - return null; - } - return sidebars[ name ]; -} -/** - * Activates the given sidebar. - * - * @param {string} name The name of the sidebar to activate. - * @return {void} - */ -export function activateSidebar( name ) { - dispatch( 'core/edit-post' ).openGeneralSidebar( name ); -} diff --git a/edit-post/components/layout/index.js b/edit-post/components/layout/index.js index 764b64dc3b558..30de1adb50b94 100644 --- a/edit-post/components/layout/index.js +++ b/edit-post/components/layout/index.js @@ -19,6 +19,7 @@ import { } from '@wordpress/editor'; import { withDispatch, withSelect } from '@wordpress/data'; import { compose } from '@wordpress/element'; +import { PluginArea } from '@wordpress/plugins'; /** * Internal dependencies @@ -31,12 +32,13 @@ import VisualEditor from '../visual-editor'; import EditorModeKeyboardShortcuts from '../keyboard-shortcuts'; import MetaBoxes from '../meta-boxes'; import { getMetaBoxContainer } from '../../utils/meta-boxes'; -import PluginsPanel from '../../components/plugins-panel/index.js'; +import PluginSidebar from '../plugin-sidebar'; function Layout( { mode, editorSidebarOpened, pluginSidebarOpened, + sidebarName, publishSidebarOpened, hasFixedToolbar, closePublishSidebar, @@ -81,8 +83,9 @@ function Layout( { /> ) } { editorSidebarOpened && } - { pluginSidebarOpened && } + { pluginSidebarOpened && } + ); } @@ -92,6 +95,7 @@ export default compose( mode: select( 'core/edit-post' ).getEditorMode(), editorSidebarOpened: select( 'core/edit-post' ).isEditorSidebarOpened(), pluginSidebarOpened: select( 'core/edit-post' ).isPluginSidebarOpened(), + sidebarName: select( 'core/edit-post' ).getActiveGeneralSidebarName(), publishSidebarOpened: select( 'core/edit-post' ).isPublishSidebarOpened(), hasFixedToolbar: select( 'core/edit-post' ).isFeatureActive( 'fixedToolbar' ), metaBoxes: select( 'core/edit-post' ).getMetaBoxes(), diff --git a/edit-post/components/plugin-sidebar/index.js b/edit-post/components/plugin-sidebar/index.js new file mode 100644 index 0000000000000..9ec20307bb3eb --- /dev/null +++ b/edit-post/components/plugin-sidebar/index.js @@ -0,0 +1,57 @@ +/** + * WordPress dependencies + */ +import { compose } from '@wordpress/element'; +import { Slot, Fill, withFocusReturn, withContext } from '@wordpress/components'; +import { withDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import './style.scss'; +import SidebarLayout from './sidebar-layout'; + +/** + * Name of slot in which the sidebar should fill. + * + * @type {String} + */ +const SLOT_NAME = 'PluginSidebar'; + +/** + * Renders the plugin sidebar fill. + * + * @return {WPElement} Plugin sidebar fill. + */ +function PluginSidebar( { pluginName, name, title, onClose, children } ) { + return ( + + + { children } + + + ); +} + +PluginSidebar = compose( [ + withDispatch( dispatch => { + return { + onClose: dispatch( 'core/edit-post' ).closeGeneralSidebar, + }; + } ), + withFocusReturn, + withContext( 'pluginName' )(), +] )( PluginSidebar ); + +/** + * The plugin sidebar slot. + * + * @return {WPElement} The plugin sidebar slot. + */ +PluginSidebar.Slot = ( { name } ) => ( + +); + +export default PluginSidebar; diff --git a/edit-post/components/plugin-sidebar/sidebar-layout.js b/edit-post/components/plugin-sidebar/sidebar-layout.js new file mode 100644 index 0000000000000..56979f25b08b8 --- /dev/null +++ b/edit-post/components/plugin-sidebar/sidebar-layout.js @@ -0,0 +1,34 @@ +/** + * WordPress dependencies + */ +import { IconButton } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import './style.scss'; + +const SidebarLayout = ( { onClose, title, children } ) => { + return ( +
+
+

{ title }

+ +
+
+ { children } +
+
+ ); +}; + +export default SidebarLayout; diff --git a/edit-post/components/plugin-sidebar/style.scss b/edit-post/components/plugin-sidebar/style.scss new file mode 100644 index 0000000000000..13e3611bad395 --- /dev/null +++ b/edit-post/components/plugin-sidebar/style.scss @@ -0,0 +1,44 @@ +.edit-post-plugin-sidebar__sidebar-layout { + position: fixed; + z-index: z-index( '.edit-post-sidebar' ); + top: 0; + right: 0; + bottom: 0; + width: $sidebar-width; + border-left: 1px solid $light-gray-500; + background: $light-gray-300; + color: $dark-gray-500; + height: 100vh; + overflow: hidden; + + @include break-small() { + top: $admin-bar-height-big + $header-height; + z-index: z-index( '.edit-post-sidebar {greater than small}' ); + height: auto; + overflow: auto; + -webkit-overflow-scrolling: touch; + } + + @include break-medium() { + top: $admin-bar-height + $header-height; + } + + &__header { + padding: 0 8px 0 16px; + height: $panel-header-height; + border-bottom: 1px solid $light-gray-500; + display: flex; + align-items: center; + + .components-icon-button { + margin-left: auto; + } + } + + &__title { + font-size: 13px; + font-weight: 600; + color: inherit; + margin: 0; + } +} diff --git a/edit-post/components/plugins-panel/index.js b/edit-post/components/plugins-panel/index.js deleted file mode 100644 index 3f693dcaa22bf..0000000000000 --- a/edit-post/components/plugins-panel/index.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; -import { IconButton, withFocusReturn } from '@wordpress/components'; -import { withDispatch, withSelect } from '@wordpress/data'; -import { compose } from '@wordpress/element'; - -/** - * Internal Dependencies - */ -import './style.scss'; -import { getSidebarSettings } from '../../api/sidebar'; - -function PluginsPanel( { onClose, pluginSidebar } ) { - if ( ! pluginSidebar ) { - return null; - } - - const { - title, - render, - } = pluginSidebar; - - return ( -
-
-

{ title }

- -
-
- { render() } -
-
- ); -} - -export default compose( - withSelect( ( select ) => ( { - pluginSidebar: getSidebarSettings( select( 'core/edit-post' ).getActiveGeneralSidebarName() ), - } ) ), - withDispatch( ( dispatch ) => ( { - onClose: dispatch( 'core/edit-post' ).closeGeneralSidebar, - } ) ), - withFocusReturn, -)( PluginsPanel ); diff --git a/edit-post/components/plugins-panel/style.scss b/edit-post/components/plugins-panel/style.scss deleted file mode 100644 index 9a1e1dfb5a4f8..0000000000000 --- a/edit-post/components/plugins-panel/style.scss +++ /dev/null @@ -1,21 +0,0 @@ -.edit-post-layout { - .edit-post-plugins-panel__header { - padding: 0 8px 0 16px; - height: $panel-header-height; - border-bottom: 1px solid $light-gray-500; - display: flex; - align-items: center; - - .components-icon-button { - margin-left: auto; - } - - h3 { - margin: 0; - font-weight: 400; - color: inherit; - } - } -} - - diff --git a/edit-post/components/sidebar/style.scss b/edit-post/components/sidebar/style.scss index 6690d88def3f9..6d655abef4bc2 100644 --- a/edit-post/components/sidebar/style.scss +++ b/edit-post/components/sidebar/style.scss @@ -90,7 +90,7 @@ .edit-post-layout.is-sidebar-opened { .edit-post-sidebar, - .edit-post-plugins-panel { + .edit-post-plugin-sidebar__sidebar-layout { /* Sidebar covers screen on mobile */ width: 100%; diff --git a/edit-post/index.js b/edit-post/index.js index ef9bf85f6463f..e22f28a07ad0d 100644 --- a/edit-post/index.js +++ b/edit-post/index.js @@ -21,7 +21,7 @@ import Layout from './components/layout'; import store from './store'; import { initializeMetaBoxState } from './store/actions'; -export * from './api'; +import PluginSidebar from './components/plugin-sidebar'; // Configure moment globally moment.locale( dateSettings.l10n.locale ); @@ -109,3 +109,7 @@ export function initializeEditor( id, post, settings ) { }, }; } + +export const __experimental = { + PluginSidebar, +}; diff --git a/edit-post/store/selectors.js b/edit-post/store/selectors.js index ad9274af18629..f34c0df9e5a2e 100644 --- a/edit-post/store/selectors.js +++ b/edit-post/store/selectors.js @@ -4,11 +4,6 @@ import createSelector from 'rememo'; import { includes, some } from 'lodash'; -/** - * Internal dependencies - */ -import { getSidebarSettings } from '../api/sidebar'; - /** * Returns the current editing mode. * @@ -39,9 +34,8 @@ export function isEditorSidebarOpened( state ) { * @return {boolean} Whether the plugin sidebar is opened. */ export function isPluginSidebarOpened( state ) { - const activeGeneralSidebar = getPreference( state, 'activeGeneralSidebar', null ); - - return Boolean( getSidebarSettings( activeGeneralSidebar ) ); + const activeGeneralSidebar = getActiveGeneralSidebarName( state ); + return !! activeGeneralSidebar && ! isEditorSidebarOpened( state ); } /** @@ -52,11 +46,7 @@ export function isPluginSidebarOpened( state ) { * @return {?string} Active general sidebar name. */ export function getActiveGeneralSidebarName( state ) { - const activeGeneralSidebar = getPreference( state, 'activeGeneralSidebar', null ); - - return activeGeneralSidebar && ( isEditorSidebarOpened( state ) || isPluginSidebarOpened( state ) ) ? - activeGeneralSidebar : - null; + return getPreference( state, 'activeGeneralSidebar', null ); } /** diff --git a/edit-post/store/test/selectors.js b/edit-post/store/test/selectors.js index 0c4db388ccf46..6c2dbbd87e8eb 100644 --- a/edit-post/store/test/selectors.js +++ b/edit-post/store/test/selectors.js @@ -13,11 +13,6 @@ import { isSavingMetaBoxes, getMetaBox, } from '../selectors'; -import { getSidebarSettings as getSidebarSettingsMock } from '../../api/sidebar'; - -jest.mock( '../../api/sidebar', () => ( { - getSidebarSettings: jest.fn().mockReturnValue( null ), -} ) ); describe( 'selectors', () => { describe( 'getEditorMode', () => { @@ -118,11 +113,7 @@ describe( 'selectors', () => { } ); it( 'should return true when the plugin sidebar is opened', () => { - getSidebarSettingsMock.mockReturnValueOnce( { - title: 'My Sidebar', - render: () => 'My Sidebar', - } ); - const name = 'my-plugin/my-sidebar'; + const name = 'plugin-sidebar/my-plugin/my-sidebar'; const state = { preferences: { activeGeneralSidebar: name, @@ -130,7 +121,6 @@ describe( 'selectors', () => { }; expect( isPluginSidebarOpened( state ) ).toBe( true ); - expect( getSidebarSettingsMock ).toHaveBeenCalledWith( name ); } ); } ); diff --git a/lib/client-assets.php b/lib/client-assets.php index f3f688a3fae2f..dac50e753312b 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -248,6 +248,7 @@ function gutenberg_register_scripts_and_styles() { 'wp-components', 'wp-utils', 'wp-viewport', + 'wp-plugins', 'word-count', 'editor', ), @@ -257,7 +258,7 @@ function gutenberg_register_scripts_and_styles() { wp_register_script( 'wp-edit-post', gutenberg_url( 'edit-post/build/index.js' ), - array( 'jquery', 'wp-element', 'wp-components', 'wp-editor', 'wp-i18n', 'wp-date', 'wp-utils', 'wp-data', 'wp-embed', 'wp-viewport' ), + array( 'jquery', 'wp-element', 'wp-components', 'wp-editor', 'wp-i18n', 'wp-date', 'wp-utils', 'wp-data', 'wp-embed', 'wp-viewport', 'wp-plugins' ), filemtime( gutenberg_dir_path() . 'edit-post/build/index.js' ), true ); @@ -307,6 +308,13 @@ function gutenberg_register_scripts_and_styles() { filemtime( gutenberg_dir_path() . 'blocks/build/edit-blocks.css' ) ); wp_style_add_data( 'wp-edit-blocks', 'rtl', 'replace' ); + + wp_register_script( + 'wp-plugins', + gutenberg_url( 'plugins/build/index.js' ), + array( 'wp-element', 'wp-components', 'wp-utils', 'wp-data' ), + filemtime( gutenberg_dir_path() . 'plugins/build/index.js' ) + ); } add_action( 'wp_enqueue_scripts', 'gutenberg_register_scripts_and_styles', 5 ); add_action( 'admin_enqueue_scripts', 'gutenberg_register_scripts_and_styles', 5 ); diff --git a/package.json b/package.json index f841d7ae6c3c8..6e23a9b796287 100644 --- a/package.json +++ b/package.json @@ -113,10 +113,10 @@ }, "jest": { "collectCoverageFrom": [ - "(blocks|components|date|editor|element|i18n|data|utils|edit-post|viewport)/**/*.js" + "(blocks|components|date|editor|element|i18n|data|utils|edit-post|viewport|plugins)/**/*.js" ], "moduleNameMapper": { - "@wordpress\\/(blocks|components|date|editor|element|i18n|data|utils|edit-post|viewport)": "$1" + "@wordpress\\/(blocks|components|date|editor|element|i18n|data|utils|edit-post|viewport|plugins)": "$1" }, "preset": "@wordpress/jest-preset-default", "setupFiles": [ diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000000000..886a8556e3547 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,61 @@ +Plugins API +==== + +The plugins API contains the following methods: + +### `wp.plugins.registerPlugin( name: string, settings: Object )` + +This method takes two arguments: + +1. `name`: A string identifying the plugin. Must be unique across all registered plugins. +2. `settings`: An object containing the following data: + - `render`: A component containing the UI elements to be rendered. See the list below for all available UI elements. + +**Example** + +```jsx +const { Fragment } = wp.element; +const { PluginSidebar } = wp.editPost.__experimental; +const { registerPlugin } = wp.plugins; + +const Component = () => ( + + + Content of the first sidebar + + + Content of the second sidebar + + +); + +registerPlugin( 'plugin-name', { + render: Component, +} ); +``` + +You can activate the sidebars using the following lines: + +```js +wp.data.dispatch( 'core/edit-post' ).openGeneralSidebar( 'plugin-name/first-sidebar-name' ); +wp.data.dispatch( 'core/edit-post' ).openGeneralSidebar( 'plugin-name/second-sidebar-name' ); +``` + +### Components + +The following components are found in the global variable `wp.plugins` when defining `wp-plugins` as a script dependency. + +#### PluginSidebar + +Renders a sidebar when activated. The contents within the `PluginSidebar` will appear as content within the sidebar. + +```jsx + + + +``` + +`PluginSidebar` accepts the following props: + +- `name`: A string identifying the sidebar. Must be unique for every sidebar registered within the scope of your plugin. +- `title`: Title displayed at the top of the sidebar. Must be a string. diff --git a/plugins/api/index.js b/plugins/api/index.js new file mode 100644 index 0000000000000..91c222b68f956 --- /dev/null +++ b/plugins/api/index.js @@ -0,0 +1,105 @@ +/* eslint no-console: [ 'error', { allow: [ 'error' ] } ] */ + +/** + * WordPress dependencies + */ +import { applyFilters } from '@wordpress/hooks'; + +/** + * External dependencies + */ +import { isFunction } from 'lodash'; + +/** + * Plugin definitions keyed by plugin name. + * + * @type {Object.} + */ +const plugins = {}; + +/** + * Registers a plugin to the editor. + * + * @param {string} name The name of the plugin. + * @param {Object} settings The settings for this plugin. + * @param {Function} settings.render The function that renders the plugin. + * + * @return {Object} The final plugin settings object. + */ +export function registerPlugin( name, settings ) { + if ( typeof settings !== 'object' ) { + console.error( + 'No settings object provided!' + ); + return null; + } + if ( typeof name !== 'string' ) { + console.error( + 'Plugin names must be strings.' + ); + return null; + } + if ( ! /^[a-z][a-z0-9-]*$/.test( name ) ) { + console.error( + 'Plugin names must include only lowercase alphanumeric characters or dashes, and start with a letter. Example: "my-plugin".' + ); + return null; + } + if ( plugins[ name ] ) { + console.error( + `Plugin "${ name }" is already registered.` + ); + } + if ( ! isFunction( settings.render ) ) { + console.error( + 'The "render" property must be specified and must be a valid function.' + ); + return null; + } + + settings.name = name; + + settings = applyFilters( 'plugins.registerPlugin', settings, name ); + + return plugins[ settings.name ] = settings; +} + +/** + * Unregisters a plugin by name. + * + * @param {string} name Plugin name. + * + * @return {?WPPlugin} The previous plugin settings object, if it has been + * successfully unregistered; otherwise `undefined`. + */ +export function unregisterPlugin( name ) { + if ( ! plugins[ name ] ) { + console.error( + 'Plugin "' + name + '" is not registered.' + ); + return; + } + const oldPlugin = plugins[ name ]; + delete plugins[ name ]; + return oldPlugin; +} + +/** + * Returns a registered plugin settings. + * + * @param {string} name Plugin name. + * + * @return {?Object} Plugin setting. + */ +export function getPlugin( name ) { + return plugins[ name ]; +} + +/** + * Returns all registered plugins. + * + * @return {Array} Plugin settings. + */ +export function getPlugins() { + return Object.values( plugins ); +} diff --git a/plugins/api/test/index.js b/plugins/api/test/index.js new file mode 100644 index 0000000000000..60aa1e72ec89d --- /dev/null +++ b/plugins/api/test/index.js @@ -0,0 +1,57 @@ +/** + * Internal dependencies + */ +import { + registerPlugin, + unregisterPlugin, + getPlugins, +} from '../'; + +describe( 'registerPlugin', () => { + afterEach( () => { + getPlugins().forEach( ( plugin ) => { + unregisterPlugin( plugin.name ); + } ); + } ); + + it( 'successfully registers a plugin', () => { + registerPlugin( 'plugin', { + render: () => 'plugin content', + } ); + } ); + + it( 'fails to register a plugin without a settings object', () => { + registerPlugin(); + expect( console ).toHaveErroredWith( 'No settings object provided!' ); + } ); + + it( 'fails to register a plugin with special character in the name', () => { + registerPlugin( 'plugin/with/special/characters', { + render: () => {}, + } ); + expect( console ).toHaveErroredWith( 'Plugin names must include only lowercase alphanumeric characters or dashes, and start with a letter. Example: "my-plugin".' ); + } ); + + it( 'fails to register a plugin with a non-string name', () => { + registerPlugin( {}, { + render: () => {}, + } ); + expect( console ).toHaveErroredWith( 'Plugin names must be strings.' ); + } ); + + it( 'fails to register a plugin without a render function', () => { + registerPlugin( 'another-plugin', {} ); + expect( console ).toHaveErroredWith( 'The "render" property must be specified and must be a valid function.' ); + } ); + + it( 'fails to register a plugin that was already been registered', () => { + registerPlugin( 'plugin', { + render: () => 'plugin content', + } ); + registerPlugin( 'plugin', { + render: () => 'plugin content', + } ); + console.log( console ); // eslint-disable-line + expect( console ).toHaveErroredWith( 'Plugin "plugin" is already registered.' ); + } ); +} ); diff --git a/plugins/components/index.js b/plugins/components/index.js new file mode 100644 index 0000000000000..f126d903a3b76 --- /dev/null +++ b/plugins/components/index.js @@ -0,0 +1,2 @@ +export { default as PluginArea } from './plugin-area'; +export { default as PluginContextProvider } from './plugin-context-provider'; diff --git a/plugins/components/plugin-area/index.js b/plugins/components/plugin-area/index.js new file mode 100644 index 0000000000000..fbb869f65040a --- /dev/null +++ b/plugins/components/plugin-area/index.js @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import { map } from 'lodash'; + +/** + * Internal dependencies + */ +import PluginContextProvider from '../plugin-context-provider'; +import { getPlugins } from '../../api'; + +/** + * A component that renders all plugin fills in a hidden div. + * + * @return {WPElement} Plugin area. + */ +function PluginArea() { + return ( +
+ { map( getPlugins(), ( plugin ) => { + const { render: Plugin } = plugin; + + return ( + + + + ); + } ) } +
+ ); +} + +export default PluginArea; diff --git a/plugins/components/plugin-context-provider/index.js b/plugins/components/plugin-context-provider/index.js new file mode 100644 index 0000000000000..95cd6931087f6 --- /dev/null +++ b/plugins/components/plugin-context-provider/index.js @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Provides context to plugins, injecting name for slot name assignnment. + */ +class PluginContextProvider extends Component { + getChildContext() { + return { + pluginName: this.props.pluginName, + }; + } + + render() { + return this.props.children; + } +} + +PluginContextProvider.childContextTypes = { + pluginName: noop, +}; + +export default PluginContextProvider; diff --git a/plugins/index.js b/plugins/index.js new file mode 100644 index 0000000000000..3d5bec67d6d1f --- /dev/null +++ b/plugins/index.js @@ -0,0 +1,2 @@ +export * from './components'; +export * from './api'; diff --git a/test/unit/setup-wp-aliases.js b/test/unit/setup-wp-aliases.js index d93003dc4079c..a5482b8acbf9b 100644 --- a/test/unit/setup-wp-aliases.js +++ b/test/unit/setup-wp-aliases.js @@ -16,6 +16,7 @@ global.wp = { 'data', 'edit-post', 'viewport', + 'plugins', ].forEach( entryPointName => { Object.defineProperty( global.wp, entryPointName, { get: () => require( entryPointName ), diff --git a/webpack.config.js b/webpack.config.js index 276ceb4c4db27..0ec60bcf8ac68 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -57,6 +57,7 @@ const entryPointNames = [ 'data', 'viewport', [ 'editPost', 'edit-post' ], + 'plugins', ]; const packageNames = [