diff --git a/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js b/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js index 4844ec6d03eb5..21f19201b7510 100644 --- a/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js +++ b/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js @@ -26,6 +26,7 @@ import { store as coreStore } from '@wordpress/core-data'; */ import { useSupportedStyles } from '../../components/global-styles/hooks'; import { unlock } from '../../lock-unlock'; +import cloneDeep from '../../utils/clone-deep'; const { cleanEmptyObject, GlobalStylesContext } = unlock( blockEditorPrivateApis @@ -275,10 +276,6 @@ function setNestedValue( object, path, value ) { return object; } -function cloneDeep( object ) { - return ! object ? {} : JSON.parse( JSON.stringify( object ) ); -} - function PushChangesToGlobalStylesControl( { name, attributes, diff --git a/packages/edit-site/src/hooks/use-theme-style-variations/test/use-theme-style-variations-by-property.js b/packages/edit-site/src/hooks/use-theme-style-variations/test/use-theme-style-variations-by-property.js new file mode 100644 index 0000000000000..ced849f8fb121 --- /dev/null +++ b/packages/edit-site/src/hooks/use-theme-style-variations/test/use-theme-style-variations-by-property.js @@ -0,0 +1,964 @@ +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import useThemeStyleVariationsByProperty, { + filterObjectByProperty, +} from '../use-theme-style-variations-by-property'; + +describe( 'filterObjectByProperty', () => { + const noop = () => {}; + test.each( [ + { + object: { + foo: 'bar', + array: [ 1, 3, 4 ], + }, + property: 'array', + expected: { array: [ 1, 3, 4 ] }, + }, + { + object: { + foo: 'bar', + }, + property: 'does-not-exist', + expected: {}, + }, + { + object: { + foo: 'bar', + }, + property: false, + expected: {}, + }, + { + object: { + dig: { + deeper: { + null: null, + }, + }, + }, + property: 'null', + expected: { + dig: { + deeper: { + null: null, + }, + }, + }, + }, + { + object: { + function: noop, + }, + property: 'function', + expected: { + function: noop, + }, + }, + { + object: [], + property: 'something', + expected: {}, + }, + { + object: {}, + property: undefined, + expected: {}, + }, + { + object: { + 'nested-object': { + 'nested-object-foo': 'bar', + array: [ 1, 3, 4 ], + }, + }, + property: 'nested-object-foo', + expected: { + 'nested-object': { + 'nested-object-foo': 'bar', + }, + }, + }, + ] )( + 'should filter object by $property', + ( { expected, object, property } ) => { + const result = filterObjectByProperty( object, property ); + expect( result ).toEqual( expected ); + } + ); +} ); + +describe( 'useThemeStyleVariationsByProperty', () => { + const mockVariations = [ + { + title: 'Title 1', + description: 'Description 1', + settings: { + color: { + duotone: [ + { + name: 'Dark grayscale', + colors: [ '#000000', '#7f7f7f' ], + slug: 'dark-grayscale', + }, + { + name: 'Grayscale', + colors: [ '#000000', '#ffffff' ], + slug: 'grayscale', + }, + { + name: 'Purple and yellow', + colors: [ '#8c00b7', '#fcff41' ], + slug: 'purple-yellow', + }, + ], + gradients: [ + { + name: 'Vivid cyan blue to vivid purple', + gradient: + 'linear-gradient(135deg,rgba(6,147,227,1) 0%,rgb(155,81,224) 100%)', + slug: 'vivid-cyan-blue-to-vivid-purple', + }, + { + name: 'Light green cyan to vivid green cyan', + gradient: + 'linear-gradient(135deg,rgb(122,220,180) 0%,rgb(0,208,130) 100%)', + slug: 'light-green-cyan-to-vivid-green-cyan', + }, + { + name: 'Luminous vivid amber to luminous vivid orange', + gradient: + 'linear-gradient(135deg,rgba(252,185,0,1) 0%,rgba(255,105,0,1) 100%)', + slug: 'luminous-vivid-amber-to-luminous-vivid-orange', + }, + ], + palette: [ + { + name: 'Vivid red', + slug: 'vivid-red', + color: '#cf2e2e', + }, + { + name: 'Luminous vivid orange', + slug: 'luminous-vivid-orange', + color: '#ff6900', + }, + { + name: 'Luminous vivid amber', + slug: 'luminous-vivid-amber', + color: '#fcb900', + }, + ], + }, + typography: { + fluid: true, + fontFamilies: { + theme: [ + { + name: 'Inter san-serif', + fontFamily: 'Inter san-serif', + slug: 'inter-san-serif', + fontFace: [ + { + src: 'inter-san-serif.woff2', + fontWeight: '400', + fontStyle: 'italic', + fontFamily: 'Inter san-serif', + }, + ], + }, + ], + }, + fontSizes: [ + { + name: 'Small', + slug: 'small', + size: '13px', + }, + { + name: 'Medium', + slug: 'medium', + size: '20px', + }, + { + name: 'Large', + slug: 'large', + size: '36px', + }, + ], + }, + layout: { + wideSize: '1200px', + }, + }, + styles: { + typography: { + letterSpacing: '3px', + }, + color: { + backgroundColor: 'red', + color: 'orange', + }, + elements: { + cite: { + color: { + text: 'white', + }, + }, + }, + blocks: { + 'core/quote': { + color: { + text: 'black', + background: 'white', + }, + typography: { + fontSize: '20px', + }, + }, + }, + }, + }, + { + title: 'Title 2', + description: 'Description 2', + settings: { + color: { + duotone: [ + { + name: 'Boom', + colors: [ '#000000', '#7f7f7f' ], + slug: 'boom', + }, + { + name: 'Gray to white', + colors: [ '#000000', '#ffffff' ], + slug: 'gray-to-white', + }, + { + name: 'Whatever to whatever', + colors: [ '#8c00b7', '#fcff41' ], + slug: 'whatever-to-whatever', + }, + ], + gradients: [ + { + name: 'Jam in the office', + gradient: + 'linear-gradient(135deg,rgba(6,147,227,1) 0%,rgb(155,81,224) 100%)', + slug: 'jam-in-the-office', + }, + { + name: 'Open source', + gradient: + 'linear-gradient(135deg,rgb(122,220,180) 0%,rgb(0,208,130) 100%)', + slug: 'open-source', + }, + { + name: 'Here to there', + gradient: + 'linear-gradient(135deg,rgba(252,185,0,1) 0%,rgba(255,105,0,1) 100%)', + slug: 'here-to-there', + }, + ], + palette: [ + { + name: 'Chunky Bacon', + slug: 'chunky-bacon', + color: '#cf2e2e', + }, + { + name: 'Burrito', + slug: 'burrito', + color: '#ff6900', + }, + { + name: 'Dinosaur', + slug: 'dinosaur', + color: '#fcb900', + }, + ], + }, + typography: { + fontSizes: [ + { + name: 'Smallish', + slug: 'smallish', + size: '15px', + }, + { + name: 'Mediumish', + slug: 'mediumish', + size: '22px', + }, + { + name: 'Largish', + slug: 'largish', + size: '44px', + }, + ], + }, + layout: { + contentSize: '300px', + }, + }, + styles: { + typography: { + letterSpacing: '3px', + }, + color: { + backgroundColor: 'red', + text: 'orange', + }, + elements: { + link: { + typography: { + textDecoration: 'underline', + }, + }, + }, + blocks: { + 'core/paragraph': { + color: { + text: 'purple', + background: 'green', + }, + typography: { + fontSize: '20px', + }, + }, + }, + }, + }, + ]; + const mockBaseVariation = { + settings: { + typography: { + fontFamilies: { + custom: [ + { + name: 'ADLaM Display', + fontFamily: 'ADLaM Display, system-ui', + slug: 'adlam-display', + fontFace: [ + { + src: 'adlam.woff2', + fontWeight: '400', + fontStyle: 'normal', + fontFamily: 'ADLaM Display', + }, + ], + }, + ], + }, + fontSizes: [ + { + name: 'Base small', + slug: 'base-small', + size: '1px', + }, + { + name: 'Base medium', + slug: 'base-medium', + size: '2px', + }, + { + name: 'Base large', + slug: 'base-large', + size: '3px', + }, + ], + }, + color: { + palette: { + custom: [ + { + color: '#c42727', + name: 'Color 1', + slug: 'custom-color-1', + }, + { + color: '#3b0f0f', + name: 'Color 2', + slug: 'custom-color-2', + }, + ], + }, + }, + layout: { + wideSize: '1137px', + contentSize: '400px', + }, + }, + styles: { + typography: { + fontSize: '12px', + lineHeight: '1.5', + }, + color: { + backgroundColor: 'cheese', + color: 'lettuce', + }, + blocks: { + 'core/quote': { + color: { + text: 'hello', + background: 'dolly', + }, + typography: { + fontSize: '111111px', + }, + }, + 'core/group': { + typography: { + fontFamily: 'var:preset|font-family|system-sans-serif', + }, + }, + }, + }, + }; + + it( 'should return variations if property is falsy', () => { + const { result } = renderHook( () => + useThemeStyleVariationsByProperty( { + variations: mockVariations, + property: '', + } ) + ); + + expect( result.current ).toEqual( mockVariations ); + } ); + + it( 'should return variations if variations is empty or falsy', () => { + const { result: emptyResult } = renderHook( () => + useThemeStyleVariationsByProperty( { + variations: [], + property: 'layout', + } ) + ); + + expect( emptyResult.current ).toEqual( [] ); + + const { result: falsyResult } = renderHook( () => + useThemeStyleVariationsByProperty( { + variations: null, + property: 'layout', + } ) + ); + + expect( falsyResult.current ).toEqual( null ); + } ); + + it( 'should return new, unreferenced object', () => { + const variations = [ + { + title: 'hey', + description: 'ho', + joe: { + where: { + you: 'going with that unit test in your hand', + }, + }, + }, + ]; + const { result } = renderHook( () => + useThemeStyleVariationsByProperty( { + variations, + property: 'where', + } ) + ); + + expect( result.current ).toEqual( [ + { + title: 'hey', + description: 'ho', + joe: { + where: { + you: 'going with that unit test in your hand', + }, + }, + }, + ] ); + + expect( result.current[ 0 ].joe.where ).not.toBe( + variations[ 0 ].joe.where + ); + expect( result.current[ 0 ].joe ).not.toBe( variations[ 0 ].joe ); + } ); + + it( "should return the variation's typography properties", () => { + const { result } = renderHook( () => + useThemeStyleVariationsByProperty( { + variations: mockVariations, + property: 'typography', + } ) + ); + + expect( result.current ).toEqual( [ + { + title: 'Title 1', + description: 'Description 1', + settings: { + typography: { + fluid: true, + fontFamilies: { + theme: [ + { + name: 'Inter san-serif', + fontFamily: 'Inter san-serif', + slug: 'inter-san-serif', + fontFace: [ + { + src: 'inter-san-serif.woff2', + fontWeight: '400', + fontStyle: 'italic', + fontFamily: 'Inter san-serif', + }, + ], + }, + ], + }, + fontSizes: [ + { + name: 'Small', + slug: 'small', + size: '13px', + }, + { + name: 'Medium', + slug: 'medium', + size: '20px', + }, + { + name: 'Large', + slug: 'large', + size: '36px', + }, + ], + }, + }, + styles: { + typography: { + letterSpacing: '3px', + }, + blocks: { + 'core/quote': { + typography: { + fontSize: '20px', + }, + }, + }, + }, + }, + { + title: 'Title 2', + description: 'Description 2', + settings: { + typography: { + fontSizes: [ + { + name: 'Smallish', + slug: 'smallish', + size: '15px', + }, + { + name: 'Mediumish', + slug: 'mediumish', + size: '22px', + }, + { + name: 'Largish', + slug: 'largish', + size: '44px', + }, + ], + }, + }, + styles: { + typography: { + letterSpacing: '3px', + }, + elements: { + link: { + typography: { + textDecoration: 'underline', + }, + }, + }, + blocks: { + 'core/paragraph': { + typography: { + fontSize: '20px', + }, + }, + }, + }, + }, + ] ); + } ); + + it( "should return the variation's color properties", () => { + const { result } = renderHook( () => + useThemeStyleVariationsByProperty( { + variations: mockVariations, + property: 'color', + } ) + ); + + expect( result.current ).toEqual( [ + { + title: 'Title 1', + description: 'Description 1', + settings: { + color: { + duotone: [ + { + name: 'Dark grayscale', + colors: [ '#000000', '#7f7f7f' ], + slug: 'dark-grayscale', + }, + { + name: 'Grayscale', + colors: [ '#000000', '#ffffff' ], + slug: 'grayscale', + }, + { + name: 'Purple and yellow', + colors: [ '#8c00b7', '#fcff41' ], + slug: 'purple-yellow', + }, + ], + gradients: [ + { + name: 'Vivid cyan blue to vivid purple', + gradient: + 'linear-gradient(135deg,rgba(6,147,227,1) 0%,rgb(155,81,224) 100%)', + slug: 'vivid-cyan-blue-to-vivid-purple', + }, + { + name: 'Light green cyan to vivid green cyan', + gradient: + 'linear-gradient(135deg,rgb(122,220,180) 0%,rgb(0,208,130) 100%)', + slug: 'light-green-cyan-to-vivid-green-cyan', + }, + { + name: 'Luminous vivid amber to luminous vivid orange', + gradient: + 'linear-gradient(135deg,rgba(252,185,0,1) 0%,rgba(255,105,0,1) 100%)', + slug: 'luminous-vivid-amber-to-luminous-vivid-orange', + }, + ], + palette: [ + { + name: 'Vivid red', + slug: 'vivid-red', + color: '#cf2e2e', + }, + { + name: 'Luminous vivid orange', + slug: 'luminous-vivid-orange', + color: '#ff6900', + }, + { + name: 'Luminous vivid amber', + slug: 'luminous-vivid-amber', + color: '#fcb900', + }, + ], + }, + }, + styles: { + color: { + backgroundColor: 'red', + color: 'orange', + }, + elements: { + cite: { + color: { + text: 'white', + }, + }, + }, + blocks: { + 'core/quote': { + color: { + text: 'black', + background: 'white', + }, + }, + }, + }, + }, + { + title: 'Title 2', + description: 'Description 2', + settings: { + color: { + duotone: [ + { + name: 'Boom', + colors: [ '#000000', '#7f7f7f' ], + slug: 'boom', + }, + { + name: 'Gray to white', + colors: [ '#000000', '#ffffff' ], + slug: 'gray-to-white', + }, + { + name: 'Whatever to whatever', + colors: [ '#8c00b7', '#fcff41' ], + slug: 'whatever-to-whatever', + }, + ], + gradients: [ + { + name: 'Jam in the office', + gradient: + 'linear-gradient(135deg,rgba(6,147,227,1) 0%,rgb(155,81,224) 100%)', + slug: 'jam-in-the-office', + }, + { + name: 'Open source', + gradient: + 'linear-gradient(135deg,rgb(122,220,180) 0%,rgb(0,208,130) 100%)', + slug: 'open-source', + }, + { + name: 'Here to there', + gradient: + 'linear-gradient(135deg,rgba(252,185,0,1) 0%,rgba(255,105,0,1) 100%)', + slug: 'here-to-there', + }, + ], + palette: [ + { + name: 'Chunky Bacon', + slug: 'chunky-bacon', + color: '#cf2e2e', + }, + { + name: 'Burrito', + slug: 'burrito', + color: '#ff6900', + }, + { + name: 'Dinosaur', + slug: 'dinosaur', + color: '#fcb900', + }, + ], + }, + }, + styles: { + color: { + backgroundColor: 'red', + text: 'orange', + }, + blocks: { + 'core/paragraph': { + color: { + text: 'purple', + background: 'green', + }, + }, + }, + }, + }, + ] ); + } ); + + it( 'should merge the user styles and settings with the supplied variation, but only for the specified property', () => { + const { result } = renderHook( () => + useThemeStyleVariationsByProperty( { + variations: [ mockVariations[ 0 ] ], + property: 'typography', + baseVariation: mockBaseVariation, + } ) + ); + + expect( result.current ).toEqual( [ + { + title: 'Title 1', + description: 'Description 1', + settings: { + typography: { + fluid: true, + fontFamilies: { + theme: [ + { + name: 'Inter san-serif', + fontFamily: 'Inter san-serif', + slug: 'inter-san-serif', + fontFace: [ + { + src: 'inter-san-serif.woff2', + fontWeight: '400', + fontStyle: 'italic', + fontFamily: 'Inter san-serif', + }, + ], + }, + ], + custom: [ + { + name: 'ADLaM Display', + fontFamily: 'ADLaM Display, system-ui', + slug: 'adlam-display', + fontFace: [ + { + src: 'adlam.woff2', + fontWeight: '400', + fontStyle: 'normal', + fontFamily: 'ADLaM Display', + }, + ], + }, + ], + }, + fontSizes: [ + { + name: 'Small', + slug: 'small', + size: '13px', + }, + { + name: 'Medium', + slug: 'medium', + size: '20px', + }, + { + name: 'Large', + slug: 'large', + size: '36px', + }, + ], + }, + color: { + palette: { + custom: [ + { + color: '#c42727', + name: 'Color 1', + slug: 'custom-color-1', + }, + { + color: '#3b0f0f', + name: 'Color 2', + slug: 'custom-color-2', + }, + ], + }, + }, + layout: { + wideSize: '1137px', + contentSize: '400px', + }, + }, + styles: { + color: { + backgroundColor: 'cheese', + color: 'lettuce', + }, + typography: { + fontSize: '12px', + letterSpacing: '3px', + lineHeight: '1.5', + }, + blocks: { + 'core/quote': { + color: { + text: 'hello', + background: 'dolly', + }, + typography: { + fontSize: '20px', + }, + }, + 'core/group': { + typography: { + fontFamily: + 'var:preset|font-family|system-sans-serif', + }, + }, + }, + }, + }, + ] ); + } ); + + it( 'should filter the output and return only variations that match filter', () => { + const { result } = renderHook( () => + useThemeStyleVariationsByProperty( { + variations: mockVariations, + property: 'typography', + filter: ( variation ) => + !! variation?.settings?.typography?.fontFamilies?.theme + ?.length, + } ) + ); + expect( result.current ).toEqual( [ + { + title: 'Title 1', + description: 'Description 1', + settings: { + typography: { + fluid: true, + fontFamilies: { + theme: [ + { + name: 'Inter san-serif', + fontFamily: 'Inter san-serif', + slug: 'inter-san-serif', + fontFace: [ + { + src: 'inter-san-serif.woff2', + fontWeight: '400', + fontStyle: 'italic', + fontFamily: 'Inter san-serif', + }, + ], + }, + ], + }, + fontSizes: [ + { + name: 'Small', + slug: 'small', + size: '13px', + }, + { + name: 'Medium', + slug: 'medium', + size: '20px', + }, + { + name: 'Large', + slug: 'large', + size: '36px', + }, + ], + }, + }, + styles: { + typography: { + letterSpacing: '3px', + }, + blocks: { + 'core/quote': { + typography: { + fontSize: '20px', + }, + }, + }, + }, + }, + ] ); + } ); +} ); diff --git a/packages/edit-site/src/hooks/use-theme-style-variations/use-theme-style-variations-by-property.js b/packages/edit-site/src/hooks/use-theme-style-variations/use-theme-style-variations-by-property.js new file mode 100644 index 0000000000000..b9c1b40ec7c1d --- /dev/null +++ b/packages/edit-site/src/hooks/use-theme-style-variations/use-theme-style-variations-by-property.js @@ -0,0 +1,92 @@ +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { mergeBaseAndUserConfigs } from '../../components/global-styles/global-styles-provider'; +import cloneDeep from '../../utils/clone-deep'; + +/** + * Returns a new object, with properties specified in `property`, + * maintain the original object tree structure. + * The function is recursive, so it will perform a deep search for the given property. + * E.g., the function will return `{ a: { b: { c: { test: 1 } } } }` if the property is `test`. + * + * @param {Object} object The object to filter + * @param {Object} property The property to filter by + * @return {Object} The merged object. + */ +export const filterObjectByProperty = ( object, property ) => { + if ( ! object ) { + return {}; + } + + const newObject = {}; + Object.keys( object ).forEach( ( key ) => { + if ( key === property ) { + newObject[ key ] = object[ key ]; + } else if ( typeof object[ key ] === 'object' ) { + const newFilter = filterObjectByProperty( object[ key ], property ); + if ( Object.keys( newFilter ).length ) { + newObject[ key ] = newFilter; + } + } + } ); + return newObject; +}; + +/** + * Returns a new object with only the properties specified in `property`. + * + * @param {Object} props Object of hook args. + * @param {Object[]} props.variations The theme style variations to filter. + * @param {string} props.property The property to filter by. + * @param {Function} props.filter Optional. The filter function to apply to the variations. + * @param {Object} props.baseVariation Optional. Base or user settings to be updated with variation properties. + * @return {Object[]|*} The merged object. + */ +export default function useThemeStyleVariationsByProperty( { + variations, + property, + filter, + baseVariation, +} ) { + return useMemo( () => { + if ( ! property || ! variations || variations?.length === 0 ) { + return variations; + } + + const clonedBaseVariation = + typeof baseVariation === 'object' && + Object.keys( baseVariation ).length > 0 + ? cloneDeep( baseVariation ) + : null; + + let processedStyleVariations = variations.map( ( variation ) => { + let result = { + ...filterObjectByProperty( cloneDeep( variation ), property ), + title: variation?.title, + description: variation?.description, + }; + + if ( clonedBaseVariation ) { + /* + * Overwrites all baseVariation object `styleProperty` properties + * with the theme variation `styleProperty` properties. + */ + result = mergeBaseAndUserConfigs( clonedBaseVariation, result ); + } + return result; + } ); + + if ( 'function' === typeof filter ) { + processedStyleVariations = + processedStyleVariations.filter( filter ); + } + + return processedStyleVariations; + }, [ variations, property, baseVariation, filter ] ); +} diff --git a/packages/edit-site/src/utils/clone-deep.js b/packages/edit-site/src/utils/clone-deep.js new file mode 100644 index 0000000000000..149e1df2408ea --- /dev/null +++ b/packages/edit-site/src/utils/clone-deep.js @@ -0,0 +1,8 @@ +/** + * Makes a copy of an object without storing any references to the original object. + * @param {Object} object + * @return {Object} The cloned object. + */ +export default function cloneDeep( object ) { + return ! object ? {} : JSON.parse( JSON.stringify( object ) ); +}