diff --git a/edit-post/README.md b/edit-post/README.md index fdfac66c203cc..b6ce382fea42c 100644 --- a/edit-post/README.md +++ b/edit-post/README.md @@ -29,6 +29,7 @@ const MyPluginSidebar = () => ( { __( 'My sidebar content' ) } @@ -53,6 +54,22 @@ Title displayed at the top of the sidebar. - Type: `String` - Required: Yes +##### isPinnable + +Whether to allow to pin sidebar to toolbar. + +- Type: `Boolean` +- Required: No +- Default: `true` + +##### icon + +The [Dashicon](https://developer.wordpress.org/resource/dashicons/) icon slug string, or an SVG WP element, to be rendered when the sidebar is pinned to toolbar. + +- Type: `String` | `Element` +- Required: No +- Default: _inherits from the plugin_ + ### `PluginSidebarMoreMenuItem` @@ -68,7 +85,7 @@ const { PluginSidebarMoreMenuItem } = wp.editPost; const MySidebarMoreMenuItem = () => ( { __( 'My sidebar title' ) } @@ -90,7 +107,7 @@ The [Dashicon](https://developer.wordpress.org/resource/dashicons/) icon slug st - Type: `String` | `Element` - Required: No - +- Default: _inherits from the plugin_ ### `PluginPostStatusInfo` diff --git a/edit-post/components/header/index.js b/edit-post/components/header/index.js index 54aa040924982..fdaa63dc777a6 100644 --- a/edit-post/components/header/index.js +++ b/edit-post/components/header/index.js @@ -17,6 +17,7 @@ import { compose } from '@wordpress/element'; import './style.scss'; import MoreMenu from './more-menu'; import HeaderToolbar from './header-toolbar'; +import PinnedPlugins from './pinned-plugins'; function Header( { isEditorSidebarOpened, @@ -52,12 +53,13 @@ function Header( { /> - + + ) } diff --git a/edit-post/components/header/pinned-plugins/index.js b/edit-post/components/header/pinned-plugins/index.js new file mode 100644 index 0000000000000..5c6c59c9a4f04 --- /dev/null +++ b/edit-post/components/header/pinned-plugins/index.js @@ -0,0 +1,28 @@ +/** + * External dependencies + */ +import { isEmpty } from 'lodash'; + +/** + * WordPress dependencies + */ +import { createSlotFill } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import './style.scss'; + +const { Fill: PinnedPlugins, Slot } = createSlotFill( 'PinnedPlugins' ); + +PinnedPlugins.Slot = ( props ) => ( + + { ( fills ) => ! isEmpty( fills ) && ( +
+ { fills } +
+ ) } +
+); + +export default PinnedPlugins; diff --git a/edit-post/components/header/pinned-plugins/style.scss b/edit-post/components/header/pinned-plugins/style.scss new file mode 100644 index 0000000000000..1dee1ff4f194b --- /dev/null +++ b/edit-post/components/header/pinned-plugins/style.scss @@ -0,0 +1,7 @@ +.edit-post-pinned-plugins { + display: flex; + + .components-icon-button { + margin-left: 4px; + } +} diff --git a/edit-post/components/header/plugin-sidebar-more-menu-item/index.js b/edit-post/components/header/plugin-sidebar-more-menu-item/index.js index d138ecf2167bf..e95ac5608cea2 100644 --- a/edit-post/components/header/plugin-sidebar-more-menu-item/index.js +++ b/edit-post/components/header/plugin-sidebar-more-menu-item/index.js @@ -11,7 +11,7 @@ import { withPluginContext } from '@wordpress/plugins'; */ import PluginsMoreMenuGroup from '../plugins-more-menu-group'; -const PluginSidebarMoreMenuItem = ( { children, isSelected, icon, onClick } ) => ( +const PluginSidebarMoreMenuItem = ( { children, icon, isSelected, onClick } ) => ( { ( fillProps ) => ( ); export default compose( - withPluginContext, - withSelect( ( select, ownProps ) => { - const { pluginContext, target } = ownProps; - const sidebarName = `${ pluginContext.name }/${ target }`; + withPluginContext( ( context, ownProps ) => { + return { + icon: ownProps.icon || context.icon, + sidebarName: `${ context.name }/${ ownProps.target }`, + }; + } ), + withSelect( ( select, { sidebarName } ) => { + const { + getActiveGeneralSidebarName, + } = select( 'core/edit-post' ); return { - isSelected: select( 'core/edit-post' ).getActiveGeneralSidebarName() === sidebarName, - sidebarName, + isSelected: getActiveGeneralSidebarName() === sidebarName, }; } ), withDispatch( ( dispatch, { isSelected, sidebarName } ) => { diff --git a/edit-post/components/sidebar/plugin-sidebar/index.js b/edit-post/components/sidebar/plugin-sidebar/index.js index e0a110645de5b..4f21ffc41a37b 100644 --- a/edit-post/components/sidebar/plugin-sidebar/index.js +++ b/edit-post/components/sidebar/plugin-sidebar/index.js @@ -1,37 +1,114 @@ /** * WordPress dependencies */ -import { Panel } from '@wordpress/components'; +import { IconButton, Panel } from '@wordpress/components'; +import { withDispatch, withSelect } from '@wordpress/data'; +import { compose, Fragment } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { withPluginContext } from '@wordpress/plugins'; /** * Internal dependencies */ +import PinnedPlugins from '../../header/pinned-plugins'; import Sidebar from '../'; import SidebarHeader from '../sidebar-header'; /** * Renders the plugin sidebar component. * + * @param {Object} props Element props. + * * @return {WPElement} Plugin sidebar component. */ -function PluginSidebar( { children, name, pluginContext, title } ) { +function PluginSidebar( props ) { + const { + children, + icon, + isActive, + isPinnable = true, + isPinned, + sidebarName, + title, + togglePin, + toggleSidebar, + } = props; + return ( - - + { isPinnable && ( + + { isPinned && } + + ) } + - { title } - - - { children } - - + + { title } + { isPinnable && ( + + ) } + + + { children } + + + ); } -export default withPluginContext( PluginSidebar ); +export default compose( + withPluginContext( ( context, ownProps ) => { + return { + icon: ownProps.icon || context.icon, + sidebarName: `${ context.name }/${ ownProps.name }`, + }; + } ), + withSelect( ( select, { sidebarName } ) => { + const { + getActiveGeneralSidebarName, + isPluginItemPinned, + } = select( 'core/edit-post' ); + + return { + isActive: getActiveGeneralSidebarName() === sidebarName, + isPinned: isPluginItemPinned( sidebarName ), + }; + } ), + withDispatch( ( dispatch, { isActive, sidebarName } ) => { + const { + closeGeneralSidebar, + openGeneralSidebar, + togglePinnedPluginItem, + } = dispatch( 'core/edit-post' ); + + return { + togglePin() { + togglePinnedPluginItem( sidebarName ); + }, + toggleSidebar() { + if ( isActive ) { + closeGeneralSidebar(); + } else { + openGeneralSidebar( sidebarName ); + } + }, + }; + } ), +)( PluginSidebar ); diff --git a/edit-post/components/sidebar/sidebar-header/style.scss b/edit-post/components/sidebar/sidebar-header/style.scss index 3fef4be85c43a..9350e87f0c210 100644 --- a/edit-post/components/sidebar/sidebar-header/style.scss +++ b/edit-post/components/sidebar/sidebar-header/style.scss @@ -22,6 +22,10 @@ display: none; margin-left: auto; + ~ .components-icon-button { + margin-left: 0; + } + @include break-medium() { display: flex; } diff --git a/edit-post/store/actions.js b/edit-post/store/actions.js index 44dfa42034f50..9dc36880564f9 100644 --- a/edit-post/store/actions.js +++ b/edit-post/store/actions.js @@ -73,7 +73,7 @@ export function toggleGeneralSidebarEditorPanel( panel ) { /** * Returns an action object used to toggle a feature flag. * - * @param {string} feature Featurre name. + * @param {string} feature Feature name. * * @return {Object} Action object. */ @@ -91,6 +91,20 @@ export function switchEditorMode( mode ) { }; } +/** + * Returns an action object used to toggle a plugin name flag. + * + * @param {string} pluginName Plugin name. + * + * @return {Object} Action object. + */ +export function togglePinnedPluginItem( pluginName ) { + return { + type: 'TOGGLE_PINNED_PLUGIN_ITEM', + pluginName, + }; +} + /** * Returns an action object used to check the state of meta boxes at a location. * diff --git a/edit-post/store/defaults.js b/edit-post/store/defaults.js index 5b20d0ec18981..05471c598a721 100644 --- a/edit-post/store/defaults.js +++ b/edit-post/store/defaults.js @@ -5,4 +5,5 @@ export const PREFERENCES_DEFAULTS = { features: { fixedToolbar: false, }, + pinnedPluginItems: {}, }; diff --git a/edit-post/store/reducer.js b/edit-post/store/reducer.js index 183d623d8f572..e335843620c13 100644 --- a/edit-post/store/reducer.js +++ b/edit-post/store/reducer.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import { get } from 'lodash'; + /** * WordPress dependencies */ @@ -63,6 +68,15 @@ export const preferences = combineReducers( { return state; }, + pinnedPluginItems( state = PREFERENCES_DEFAULTS.pinnedPluginItems, action ) { + if ( action.type === 'TOGGLE_PINNED_PLUGIN_ITEM' ) { + return { + ...state, + [ action.pluginName ]: ! get( state, [ action.pluginName ], true ), + }; + } + return state; + }, } ); export function panel( state = 'document', action ) { diff --git a/edit-post/store/selectors.js b/edit-post/store/selectors.js index f34c0df9e5a2e..7ba47508ca782 100644 --- a/edit-post/store/selectors.js +++ b/edit-post/store/selectors.js @@ -2,7 +2,7 @@ * External dependencies */ import createSelector from 'rememo'; -import { includes, some } from 'lodash'; +import { get, includes, some } from 'lodash'; /** * Returns the current editing mode. @@ -108,6 +108,21 @@ export function isFeatureActive( state, feature ) { return !! state.preferences.features[ feature ]; } +/** + * Returns true if the the plugin item is pinned to the header. + * When the value is not set it defaults to true. + * + * @param {Object} state Global application state. + * @param {string} pluginName Plugin item name. + * + * @return {boolean} Whether the plugin item is pinned. + */ +export function isPluginItemPinned( state, pluginName ) { + const pinnedPluginItems = getPreference( state, 'pinnedPluginItems', {} ); + + return get( pinnedPluginItems, [ pluginName ], true ); +} + /** * Returns the state of legacy meta boxes. * diff --git a/edit-post/store/test/actions.js b/edit-post/store/test/actions.js index 5da68294a71cd..c0ae7ea9753aa 100644 --- a/edit-post/store/test/actions.js +++ b/edit-post/store/test/actions.js @@ -9,6 +9,7 @@ import { closePublishSidebar, togglePublishSidebar, toggleFeature, + togglePinnedPluginItem, requestMetaBoxUpdates, initializeMetaBoxState, } from '../actions'; @@ -76,6 +77,17 @@ describe( 'actions', () => { } ); } ); + describe( 'togglePinnedPluginItem', () => { + it( 'should return TOGGLE_PINNED_PLUGIN_ITEM action', () => { + const pluginName = 'foo/bar'; + + expect( togglePinnedPluginItem( pluginName ) ).toEqual( { + type: 'TOGGLE_PINNED_PLUGIN_ITEM', + pluginName, + } ); + } ); + } ); + describe( 'requestMetaBoxUpdates', () => { it( 'should return the REQUEST_META_BOX_UPDATES action', () => { expect( requestMetaBoxUpdates() ).toEqual( { diff --git a/edit-post/store/test/reducer.js b/edit-post/store/test/reducer.js index 8c1742f258da9..18bdf6cfbbd6e 100644 --- a/edit-post/store/test/reducer.js +++ b/edit-post/store/test/reducer.js @@ -22,6 +22,7 @@ describe( 'state', () => { editorMode: 'visual', panels: { 'post-status': true }, features: { fixedToolbar: false }, + pinnedPluginItems: {}, } ); } ); @@ -50,6 +51,7 @@ describe( 'state', () => { editorMode: 'visual', panels: { 'post-status': true }, features: { fixedToolbar: false }, + pinnedPluginItems: {}, } ); } ); @@ -113,6 +115,42 @@ describe( 'state', () => { expect( state.features ).toEqual( { chicken: false } ); } ); + + describe( 'pinnedPluginItems', () => { + const initialState = deepFreeze( { + pinnedPluginItems: { + 'foo/enabled': true, + 'foo/disabled': false, + }, + } ); + + it( 'should disable a pinned plugin flag when the value does not exist', () => { + const state = preferences( initialState, { + type: 'TOGGLE_PINNED_PLUGIN_ITEM', + pluginName: 'foo/does-not-exist', + } ); + + expect( state.pinnedPluginItems[ 'foo/does-not-exist' ] ).toBe( false ); + } ); + + it( 'should disable a pinned plugin flag when it is enabled', () => { + const state = preferences( initialState, { + type: 'TOGGLE_PINNED_PLUGIN_ITEM', + pluginName: 'foo/enabled', + } ); + + expect( state.pinnedPluginItems[ 'foo/enabled' ] ).toBe( false ); + } ); + + it( 'should enable a pinned plugin flag when it is disabled', () => { + const state = preferences( initialState, { + type: 'TOGGLE_PINNED_PLUGIN_ITEM', + pluginName: 'foo/disabled', + } ); + + expect( state.pinnedPluginItems[ 'foo/disabled' ] ).toBe( true ); + } ); + } ); } ); describe( 'isSavingMetaBoxes', () => { diff --git a/edit-post/store/test/selectors.js b/edit-post/store/test/selectors.js index 6c2dbbd87e8eb..d706c4b3ecfc6 100644 --- a/edit-post/store/test/selectors.js +++ b/edit-post/store/test/selectors.js @@ -8,6 +8,7 @@ import { isEditorSidebarPanelOpened, isFeatureActive, isPluginSidebarOpened, + isPluginItemPinned, getMetaBoxes, hasMetaBoxes, isSavingMetaBoxes, @@ -186,6 +187,30 @@ describe( 'selectors', () => { expect( isFeatureActive( state, 'chicken' ) ).toBe( false ); } ); } ); + + describe( 'isPluginItemPinned', () => { + const state = { + preferences: { + pinnedPluginItems: { + 'foo/pinned': true, + 'foo/unpinned': false, + }, + }, + }; + + it( 'should return true if the flag is not set for the plugin item', () => { + expect( isPluginItemPinned( state, 'foo/unknown' ) ).toBe( true ); + } ); + + it( 'should return true if plugin item is not pinned', () => { + expect( isPluginItemPinned( state, 'foo/pinned' ) ).toBe( true ); + } ); + + it( 'should return false if plugin item item is unpinned', () => { + expect( isPluginItemPinned( state, 'foo/unpinned' ) ).toBe( false ); + } ); + } ); + describe( 'hasMetaBoxes', () => { it( 'should return true if there are active meta boxes', () => { const state = { diff --git a/plugins/README.md b/plugins/README.md index 4bcd3eb0e939f..a67e9e250b7a1 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -12,7 +12,10 @@ 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. + - `icon: string | WPElement | Function` - An icon to be shown in the UI. It can be a slug + of the [Dashicon](https://developer.wordpress.org/resource/dashicons/#awards), + or an element (or function returning an element) if you choose to render your own SVG. + - `render`: A component containing the UI elements to be rendered. See [the edit-post module documentation](../edit-post/) for available components. @@ -27,7 +30,6 @@ const Component = () => ( My Sidebar @@ -41,6 +43,7 @@ const Component = () => ( ); registerPlugin( 'plugin-name', { + icon: 'smiley', render: Component, } ); ``` diff --git a/plugins/api/index.js b/plugins/api/index.js index 46d474512ee68..bb5873ad01bf3 100644 --- a/plugins/api/index.js +++ b/plugins/api/index.js @@ -20,9 +20,10 @@ 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. + * @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. + * @param {string|WPElement|Function} settings.icon An icon to be shown in the UI. * * @return {Object} The final plugin settings object. */ @@ -50,6 +51,9 @@ export function registerPlugin( name, settings ) { `Plugin "${ name }" is already registered.` ); } + + settings = applyFilters( 'plugins.registerPlugin', settings, name ); + if ( ! isFunction( settings.render ) ) { console.error( 'The "render" property must be specified and must be a valid function.' @@ -57,11 +61,11 @@ export function registerPlugin( name, settings ) { return null; } - settings.name = name; - - settings = applyFilters( 'plugins.registerPlugin', settings, name ); - - plugins[ settings.name ] = settings; + plugins[ name ] = { + name, + icon: 'admin-plugins', + ...settings, + }; doAction( 'plugins.pluginRegistered', settings, name ); diff --git a/plugins/api/test/index.js b/plugins/api/test/index.js index 60aa1e72ec89d..fb11c32204da3 100644 --- a/plugins/api/test/index.js +++ b/plugins/api/test/index.js @@ -4,6 +4,7 @@ import { registerPlugin, unregisterPlugin, + getPlugin, getPlugins, } from '../'; @@ -15,8 +16,19 @@ describe( 'registerPlugin', () => { } ); it( 'successfully registers a plugin', () => { - registerPlugin( 'plugin', { - render: () => 'plugin content', + const name = 'plugin'; + const icon = 'smiley'; + const Component = () => 'plugin content'; + + registerPlugin( name, { + render: Component, + icon, + } ); + + expect( getPlugin( name ) ).toEqual( { + name, + render: Component, + icon, } ); } ); @@ -51,7 +63,6 @@ describe( 'registerPlugin', () => { registerPlugin( 'plugin', { render: () => 'plugin content', } ); - console.log( console ); // eslint-disable-line expect( console ).toHaveErroredWith( 'Plugin "plugin" is already registered.' ); } ); } ); diff --git a/plugins/components/plugin-area/index.js b/plugins/components/plugin-area/index.js index a5c22a2dc95d6..4bfd74176991f 100644 --- a/plugins/components/plugin-area/index.js +++ b/plugins/components/plugin-area/index.js @@ -30,12 +30,12 @@ class PluginArea extends Component { getCurrentPluginsState() { return { - plugins: map( getPlugins(), ( { name, render } ) => { + plugins: map( getPlugins(), ( { icon, name, render } ) => { return { - name, Plugin: render, context: { name, + icon, }, }; } ), @@ -59,9 +59,9 @@ class PluginArea extends Component { render() { return (
- { map( this.state.plugins, ( { context, name, Plugin } ) => ( + { map( this.state.plugins, ( { context, Plugin } ) => ( diff --git a/plugins/components/plugin-context/index.js b/plugins/components/plugin-context/index.js index 2ee6e74f55600..3bf3030780bc7 100644 --- a/plugins/components/plugin-context/index.js +++ b/plugins/components/plugin-context/index.js @@ -5,28 +5,30 @@ import { createContext, createHigherOrderComponent } from '@wordpress/element'; const { Consumer, Provider } = createContext( { name: null, + icon: null, } ); export { Provider as PluginContextProvider }; /** - * A Higher-order Component used to inject Plugin context into the wrapped - * component. + * A Higher Order Component used to inject Plugin context to the + * wrapped component. * - * @param {Component} OriginalComponent Component to wrap. + * @param {Function} mapContextToProps Function called on every context change, + * expected to return object of props to + * merge with the component's own props. * - * @return {Component} Component with Plugin context injected. + * @return {Component} Enhanced component with injected context as props. */ -export const withPluginContext = createHigherOrderComponent( - ( OriginalComponent ) => ( props ) => ( +export const withPluginContext = ( mapContextToProps ) => createHigherOrderComponent( ( OriginalComponent ) => { + return ( props ) => ( - { ( pluginContext ) => ( + { ( context ) => ( ) } - ), - 'withPluginContext' -); + ); +}, 'withPluginContext' );