From 860a192aff99f0adb2f6be1339fbf5fde75a900e Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Thu, 6 Aug 2026 15:19:50 -0500 Subject: [PATCH] fix: return createBlock instances from block transforms and fix filter CSS selectors Two related bug fixes: - Transforms: `type: 'block'` callbacks must return a block instance created by createBlock; the previous code returned raw attribute objects which WordPress silently ignored, so the toolbar block-switcher transforms never fired. Update tests to assert on `result.name` and `result.attributes`. Add a manual jest mock for @wordpress/blocks since the package is provided at runtime via dependency-extraction-webpack-plugin. - Filter CSS: the SCSS used a descendant combinator (`.wp-block-ksolo-image-filter .has-filter-warm img`) but the JSX puts both classes on the same element, so the filter rule never matched and picking a filter had no visual effect on the editor or the front end. Target the inner by its stable class pair (`.ksolo-image-filter-img.has-filter-`) instead. Add a regression test that reads the compiled CSS and asserts the selectors are present for every preset. --- src/transforms.js | 13 +++-- tests/jest/__mocks__/@wordpress/blocks.js | 15 ++++++ tests/jest/styles.test.js | 63 +++++++++++++++++++++++ tests/jest/transforms.test.js | 44 ++++++++++------ 4 files changed, 116 insertions(+), 19 deletions(-) create mode 100644 tests/jest/__mocks__/@wordpress/blocks.js create mode 100644 tests/jest/styles.test.js diff --git a/src/transforms.js b/src/transforms.js index 8e75da2..f040c38 100644 --- a/src/transforms.js +++ b/src/transforms.js @@ -5,9 +5,14 @@ * - "From" core Image: copies image attributes, sets filter="normal", * intensity=100. * + * Per the Block Transforms API, `type: 'block'` transforms must return a + * block instance produced by `createBlock` from `@wordpress/blocks` — a + * raw attribute object is silently ignored and the transform fails. + * * @package SoloFiltersImageEnhancements */ +import { createBlock } from '@wordpress/blocks'; import { DEFAULT_PRESET } from './presets'; const IMAGE_ATTR_MAP = { @@ -69,16 +74,18 @@ export const transforms = { { type: 'block', blocks: [ 'core/image' ], - transform: ( attributes ) => toImageAttrs( attributes ), + transform: ( attributes ) => + createBlock( 'core/image', toImageAttrs( attributes ) ), }, ], from: [ { type: 'block', blocks: [ 'core/image' ], - transform: ( attributes ) => fromImageAttrs( attributes ), + transform: ( attributes ) => + createBlock( 'ksolo/image-filter', fromImageAttrs( attributes ) ), }, ], }; -export default transforms; \ No newline at end of file +export default transforms; diff --git a/tests/jest/__mocks__/@wordpress/blocks.js b/tests/jest/__mocks__/@wordpress/blocks.js new file mode 100644 index 0000000..79e7d71 --- /dev/null +++ b/tests/jest/__mocks__/@wordpress/blocks.js @@ -0,0 +1,15 @@ +/** + * Manual mock for @wordpress/blocks. + * + * 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 Jest's module resolver find something + * when the transforms test (or any other code that imports + * @wordpress/blocks) runs in the unit-test environment. + * + * The mock faithfully reproduces the shape createBlock returns: a block + * instance with `name` and `attributes` fields. + */ +module.exports = { + createBlock: ( name, attributes ) => ( { name, attributes } ), +}; diff --git a/tests/jest/styles.test.js b/tests/jest/styles.test.js new file mode 100644 index 0000000..8d60f8d --- /dev/null +++ b/tests/jest/styles.test.js @@ -0,0 +1,63 @@ +/** + * Regression test for the compiled filter CSS. + * + * Earlier the source SCSS wrote the filter rules with a descendant + * combinator (`.wp-block-ksolo-image-filter .has-filter-warm img`), but + * the JSX puts `wp-block-ksolo-image-filter` and `has-filter-` on + * the SAME element, and the inner carries `ksolo-image-filter-img` + * and the same `has-filter-` class. The descendant combinator never + * matched, so the `filter: ...` declarations were never applied — picking + * a filter in the editor or on the front end had no visual effect. + * + * The fix targets the by its stable, always-present pair of classes + * (`.ksolo-image-filter-img.has-filter-`) scoped under the wrapper. + * This test reads the compiled CSS and asserts the selectors and `filter` + * declarations are present for every preset slug in src/presets.js. + * + * The test reads from the build artifact rather than the SCSS source + * because that is the CSS WordPress actually serves. If you change + * style.scss, re-run `npm run build` to update build/style-index.css. + */ +import fs from 'fs'; +import path from 'path'; +import { PRESETS } from '../../src/presets'; + +const cssPath = path.join( __dirname, '..', '..', 'build', 'style-index.css' ); + +describe( 'compiled filter CSS', () => { + let css; + + beforeAll( () => { + css = fs.readFileSync( cssPath, 'utf8' ); + } ); + + test.each( + PRESETS.filter( ( p ) => p.slug !== 'normal' ).map( ( p ) => [ p.slug ] ) + )( + 'declares a filter rule for the %s preset that targets the ', + ( slug ) => { + // The selector must target the by its stable class pair + // (ksolo-image-filter-img + has-filter-) scoped under the + // wrapper, with NO space between the two class names (compound + // selector, not descendant). + // The 'normal' preset is unfiltered; the base + // `.wp-block-ksolo-image-filter img { filter: none; }` rule + // covers it, so we skip it here. + const expectedSelector = `.wp-block-ksolo-image-filter .ksolo-image-filter-img.has-filter-${ slug }`; + expect( css ).toContain( expectedSelector ); + } + ); + + test( 'does not regress to the broken descendant-selector form', () => { + // Guard against the original bug reappearing: a descendant + // combinator between the two class names never matches the + // actual markup, where both classes live on the same element. + for ( const { slug } of PRESETS ) { + if ( slug === 'normal' ) { + continue; // 'normal' is unfiltered, no rule needed. + } + const broken = `.wp-block-ksolo-image-filter .has-filter-${ slug } img`; + expect( css ).not.toContain( broken ); + } + } ); +} ); diff --git a/tests/jest/transforms.test.js b/tests/jest/transforms.test.js index f8bef66..4ac7e60 100644 --- a/tests/jest/transforms.test.js +++ b/tests/jest/transforms.test.js @@ -1,3 +1,7 @@ +// @wordpress/blocks is provided by WordPress at runtime via the +// dependency-extraction-webpack-plugin. The Jest test environment has no +// WordPress globals, so a manual mock lives at +// tests/jest/__mocks__/@wordpress/blocks.js. Jest auto-resolves it. import { transforms } from '../../src/transforms'; describe( 'transforms', () => { @@ -25,9 +29,9 @@ describe( 'transforms', () => { ); } ); - test( 'to-transform drops filter and intensity', () => { + test( 'to-transform produces a core/image block that drops filter and intensity', () => { const to = Array.isArray( transforms.to ) ? transforms.to[ 0 ] : transforms.to; - const mapped = to.transform( { + const result = to.transform( { filter: 'dramatic', intensity: 80, imageId: 12, @@ -38,15 +42,20 @@ describe( 'transforms', () => { linkUrl: '', caption: '', } ); - expect( mapped ).not.toHaveProperty( 'filter' ); - expect( mapped ).not.toHaveProperty( 'intensity' ); - expect( mapped.id ).toBe( 12 ); - expect( mapped.url ).toBe( 'https://example.com/a.jpg' ); + // WordPress block-to-block transforms must return a block instance + // (created via createBlock), not a bare attribute object. + expect( result ).toBeDefined(); + expect( result.name ).toBe( 'core/image' ); + expect( result.attributes ).toBeDefined(); + expect( result.attributes ).not.toHaveProperty( 'filter' ); + expect( result.attributes ).not.toHaveProperty( 'intensity' ); + expect( result.attributes.id ).toBe( 12 ); + expect( result.attributes.url ).toBe( 'https://example.com/a.jpg' ); } ); - test( 'from-transform normalises filter and intensity to defaults', () => { + test( 'from-transform produces a ksolo/image-filter block with normalised filter and intensity', () => { const from = Array.isArray( transforms.from ) ? transforms.from[ 0 ] : transforms.from; - const mapped = from.transform( { + const result = from.transform( { id: 12, url: 'https://example.com/a.jpg', alt: 'A', @@ -55,12 +64,15 @@ describe( 'transforms', () => { height: 100, href: 'https://example.com', } ); - expect( mapped.filter ).toBe( 'normal' ); - expect( mapped.intensity ).toBe( 100 ); - expect( mapped.imageId ).toBe( 12 ); - expect( mapped.imageUrl ).toBe( 'https://example.com/a.jpg' ); - expect( mapped.imageAlt ).toBe( 'A' ); - expect( mapped.caption ).toBe( 'cap' ); - expect( mapped.linkUrl ).toBe( 'https://example.com' ); + expect( result ).toBeDefined(); + expect( result.name ).toBe( 'ksolo/image-filter' ); + expect( result.attributes ).toBeDefined(); + expect( result.attributes.filter ).toBe( 'normal' ); + expect( result.attributes.intensity ).toBe( 100 ); + expect( result.attributes.imageId ).toBe( 12 ); + expect( result.attributes.imageUrl ).toBe( 'https://example.com/a.jpg' ); + expect( result.attributes.imageAlt ).toBe( 'A' ); + expect( result.attributes.caption ).toBe( 'cap' ); + expect( result.attributes.linkUrl ).toBe( 'https://example.com' ); } ); -} ); \ No newline at end of file +} );