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' ); + } ); +} );