From 9ea16bf6504f65a170f4bd23edd3be4ea85ce347 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Thu, 6 Aug 2026 15:21:25 -0500 Subject: [PATCH] fix: mount filter controls in sidebar via InspectorControls The FilterPanel was being mounted as a free-floating sibling of the block via an `editor.BlockEdit` HOC. In WordPress, a `PanelBody` only renders in the sidebar when wrapped in ``; without that wrapper, the panel renders wherever it lands in the React tree, which here was the editor canvas. Move the InspectorControls mount into the Edit component itself (the idiomatic place for an own block to expose sidebar controls), and drop the `editor.BlockEdit` filter from `src/index.js`. Add an Edit test that pins the contract: the filter panel must mount inside InspectorControls and never float outside it. Add a manual jest mock for @wordpress/block-editor so the Edit test can render in an environment without WordPress globals. --- src/edit.js | 133 +++++++++++------- src/index.js | 29 +--- .../jest/__mocks__/@wordpress/block-editor.js | 39 +++++ tests/jest/edit.test.js | 98 +++++++++++++ 4 files changed, 219 insertions(+), 80 deletions(-) create mode 100644 tests/jest/__mocks__/@wordpress/block-editor.js create mode 100644 tests/jest/edit.test.js diff --git a/src/edit.js b/src/edit.js index fd9c605..a77f19a 100644 --- a/src/edit.js +++ b/src/edit.js @@ -3,19 +3,29 @@ * * The MediaPlaceholder is the same UX as the core Image block. After an * image is selected, the block renders the image with the filter class - * and intensity CSS custom property. The Inspector panel is mounted - * separately. + * and intensity CSS custom property. The filter presets and intensity + * slider live in the sidebar via `InspectorControls` — a `PanelBody` only + * renders in the sidebar when wrapped in `InspectorControls`, so the + * block's edit component itself mounts the panel rather than relying on + * an `editor.BlockEdit` HOC. * * @package SoloFiltersImageEnhancements */ import { __ } from '@wordpress/i18n'; -import { useBlockProps, MediaPlaceholder, BlockControls } from '@wordpress/block-editor'; +import { + useBlockProps, + MediaPlaceholder, + BlockControls, + InspectorControls, +} from '@wordpress/block-editor'; import { Button, ToolbarGroup, ToolbarItem } from '@wordpress/components'; import { useState } from '@wordpress/element'; +import { FilterPanel } from './inspector'; + export default function Edit( { attributes, setAttributes } ) { - const { filter, intensity, imageId, imageUrl, imageAlt, width, height } = attributes; + const { filter, intensity, imageUrl, imageAlt, width, height } = attributes; const blockProps = useBlockProps( { className: `wp-block-ksolo-image-filter has-filter-${ filter }`, style: { '--filter-intensity': String( intensity / 100 ) }, @@ -23,58 +33,77 @@ export default function Edit( { attributes, setAttributes } ) { const [ isEditing, setIsEditing ] = useState( ! imageUrl ); + // The inspector panel is mounted alongside whatever the block renders + // in the canvas. It is available in both the empty-placeholder state + // and the rendered-image state, so users can pre-pick a filter before + // uploading. + const inspector = ( + + + + ); + if ( isEditing || ! imageUrl ) { return ( -
- { - setAttributes( { - imageId: media.id, - imageUrl: media.url, - imageAlt: media.alt || '', - width: media.width, - height: media.height, - } ); - setIsEditing( false ); - } } - allowedTypes={ [ 'image' ] } - multiple={ false } - labels={ { - title: __( 'Filtered Image', 'solofilters-image-enhancements' ), - instructions: __( - 'Upload or select an image to apply a filter.', - 'solofilters-image-enhancements' - ), - } } - /> -
+ <> + { inspector } +
+ { + setAttributes( { + imageId: media.id, + imageUrl: media.url, + imageAlt: media.alt || '', + width: media.width, + height: media.height, + } ); + setIsEditing( false ); + } } + allowedTypes={ [ 'image' ] } + multiple={ false } + labels={ { + title: __( 'Filtered Image', 'solofilters-image-enhancements' ), + instructions: __( + 'Upload or select an image to apply a filter.', + 'solofilters-image-enhancements' + ), + } } + /> +
+ ); } return ( -
- - - - { () => ( - - ) } - - - - { -
+ <> + { inspector } +
+ + + + { () => ( + + ) } + + + + { +
+ ); -} +} \ No newline at end of file diff --git a/src/index.js b/src/index.js index 560866f..fea04a5 100644 --- a/src/index.js +++ b/src/index.js @@ -14,7 +14,6 @@ import metadata from './block.json'; import edit from './edit'; import save from './save'; import { PRESETS, DEFAULT_PRESET, isValidPresetSlug } from './presets'; -import { FilterPanel } from './inspector'; import { transforms } from './transforms'; registerBlockType( metadata.name, { @@ -24,32 +23,6 @@ registerBlockType( metadata.name, { transforms, } ); -/** - * Add the filter panel to the Image block's inspector when the active - * block is the Filtered Image block. We use the editor.BlockEdit filter - * so we don't have to re-implement the entire MediaPlaceholder UX. - * - * @param {Function} BlockEdit - * @return {Function} - */ -function withFilterPanel( BlockEdit ) { - return ( props ) => { - if ( props.name !== 'ksolo/image-filter' ) { - return ; - } - return ( - <> - - - - ); - }; -} -addFilter( 'editor.BlockEdit', 'solofilters-image-enhancements/with-filter-panel', withFilterPanel ); - /** * Normalise the filter attribute on save. If the saved slug is unknown * (e.g. the post was edited by hand), fall back to DEFAULT_PRESET so the @@ -76,4 +49,4 @@ addFilter( // Re-export PRESETS so the consuming downstream code (e.g. a future // design-tool integration) can grab them from this module. -export { PRESETS }; +export { PRESETS }; \ No newline at end of file diff --git a/tests/jest/__mocks__/@wordpress/block-editor.js b/tests/jest/__mocks__/@wordpress/block-editor.js new file mode 100644 index 0000000..230b2a8 --- /dev/null +++ b/tests/jest/__mocks__/@wordpress/block-editor.js @@ -0,0 +1,39 @@ +/** + * Manual mock for @wordpress/block-editor. + * + * The package is provided by WordPress at runtime via the + * dependency-extraction-webpack-plugin and is not installed as a dev + * dependency. This mock lets the Jest environment resolve it. + * + * Slot components (`InspectorControls`, `BlockControls`) are rendered as + * lightweight `
` wrappers so tests can assert that + * `Edit` mounts them in the right place. Hooks (`useBlockProps`) are + * stubbed to return the supplied props unchanged, which lets the rest + * of the React tree render with stable className/style. + */ +import { createElement } from '@wordpress/element'; + +const Slot = ( { name, children } ) => + createElement( 'div', { 'data-slot': name }, children ); + +function makeUseBlockProps( props = {} ) { + return { + className: props.className || '', + style: props.style || {}, + }; +} + +const useBlockPropsFn = ( props ) => makeUseBlockProps( props ); +useBlockPropsFn.save = ( props ) => makeUseBlockProps( props ); + +export const useBlockProps = useBlockPropsFn; +export const InspectorControls = ( { children } ) => + createElement( Slot, { name: 'inspector-controls' }, children ); +export const BlockControls = ( { children } ) => + createElement( Slot, { name: 'block-controls' }, children ); +export const MediaPlaceholder = ( props ) => + createElement( + 'div', + { 'data-mock': 'MediaPlaceholder' }, + props.labels ? props.labels.title : null + ); diff --git a/tests/jest/edit.test.js b/tests/jest/edit.test.js new file mode 100644 index 0000000..1918c41 --- /dev/null +++ b/tests/jest/edit.test.js @@ -0,0 +1,98 @@ +/** + * @jest-environment jsdom + * + * Tests for the Edit component. The core contract these tests pin down + * is that the filter controls are mounted in the sidebar via + * `InspectorControls` (not floating in the canvas). The block's `Edit` + * component is responsible for wrapping the `FilterPanel` in + * `InspectorControls` itself — see the comment block at the top of + * src/edit.js. + */ +// @wordpress/element, @wordpress/block-editor, @wordpress/components, +// @wordpress/i18n are all stubbed via __mocks__/* and inline jest.mock +// calls so the test environment has no WordPress globals. +import { render, screen } from '@testing-library/react'; +import Edit from '../../src/edit'; + +const defaultAttributes = { + filter: 'normal', + intensity: 100, + imageId: 0, + imageUrl: '', + imageAlt: '', + width: 100, + height: 100, + linkUrl: '', + caption: '', +}; + +const baseProps = { + attributes: defaultAttributes, + setAttributes: jest.fn(), + isSelected: true, + clientId: 'test-client-id', + context: {}, + name: 'ksolo/image-filter', +}; + +describe( 'Edit (Filtered Image block)', () => { + test( 'mounts the filter controls inside InspectorControls (sidebar)', () => { + const { container } = render( ); + + // InspectorControls is rendered as
. + const sidebar = container.querySelector( + '[data-slot="inspector-controls"]' + ); + expect( sidebar ).not.toBeNull(); + + // The PanelBody inside FilterPanel uses the title "Filter". Even + // in the empty/placeholder branch (no imageUrl), the inspector + // should be present so users can pick a preset before uploading. + expect( + sidebar.querySelector( '.ksolo-image-filter-panel' ) + ).not.toBeNull(); + } ); + + test( 'does NOT mount a floating FilterPanel outside InspectorControls', () => { + // Regression guard: earlier the `FilterPanel` was rendered as a + // bare `` (no InspectorControls wrapper) via an + // `editor.BlockEdit` HOC. That caused the controls to appear in + // the editor canvas rather than the sidebar. The new wiring + // guarantees the panel only appears inside InspectorControls. + const { container } = render( ); + const allPanels = container.querySelectorAll( + '.ksolo-image-filter-panel' + ); + expect( allPanels.length ).toBe( 1 ); + expect( + allPanels[ 0 ].closest( '[data-slot="inspector-controls"]' ) + ).not.toBeNull(); + } ); + + test( 'shows the MediaPlaceholder in the canvas when no image is set', () => { + render( ); + // The MediaPlaceholder mock renders a div with + // `data-mock="MediaPlaceholder"` containing the labels.title + // string ("Filtered Image"). + const placeholder = screen.getByText( 'Filtered Image' ); + expect( placeholder ).not.toBeNull(); + } ); + + test( 'renders the in the canvas when an imageUrl is set', () => { + const props = { + ...baseProps, + attributes: { + ...defaultAttributes, + imageUrl: 'https://example.com/a.jpg', + imageAlt: 'A', + }, + }; + render( ); + const img = screen.getByAltText( 'A' ); + expect( img ).not.toBeNull(); + // The className on the must include the preset class so the + // compiled CSS can target it. + expect( img.className ).toContain( 'ksolo-image-filter-img' ); + expect( img.className ).toContain( 'has-filter-normal' ); + } ); +} );