diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 307e5cef0e2296..f2f391b582f801 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -193,6 +193,28 @@ export function __experimentalReceiveThemeGlobalStyleVariations( }; } +/** + * Returns an action object used in signalling that the user global styles variations have been received. + * Ignored from documentation as it's internal to the data store. + * + * @ignore + * + * @param {string} stylesheet Stylesheet. + * @param {Array} variations The global styles variations. + * + * @return {Object} Action object. + */ +export function __experimentalReceiveUserGlobalStyleVariations( + stylesheet, + variations +) { + return { + type: 'RECEIVE_USER_GLOBAL_STYLE_VARIATIONS', + stylesheet, + variations, + }; +} + /** * Returns an action object used in signalling that the index has been received. * @@ -834,3 +856,40 @@ export function receiveAutosaves( postId, autosaves ) { autosaves: Array.isArray( autosaves ) ? autosaves : [ autosaves ], }; } + +/** + * Discards changes in the specified record. + * + * @param {string} kind Entity kind. + * @param {string} name Entity name. + * @param {*} recordId Record ID. + * + */ +export const __experimentalDiscardRecordChanges = + ( kind, name, recordId ) => + ( { dispatch, select } ) => { + const edits = select.getEntityRecordEdits( kind, name, recordId ); + + if ( ! edits ) { + return; + } + + const clearedEdits = Object.keys( edits ).reduce( ( acc, key ) => { + return { + ...acc, + [ key ]: undefined, + }; + }, {} ); + + dispatch( { + type: 'EDIT_ENTITY_RECORD', + kind, + name, + recordId, + edits: clearedEdits, + transientEdits: clearedEdits, + meta: { + isUndo: false, + }, + } ); + }; diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 21ecaff436c72c..bdf70a85c37cf3 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -187,6 +187,26 @@ export function themeGlobalStyleVariations( state = {}, action ) { return state; } +/** + * Reducer managing the user global styles variations. + * + * @param {Record} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Record} Updated state. + */ +export function userGlobalStyleVariations( state = {}, action ) { + switch ( action.type ) { + case 'RECEIVE_USER_GLOBAL_STYLE_VARIATIONS': + return { + ...state, + [ action.stylesheet ]: action.variations, + }; + } + + return state; +} + /** * Higher Order Reducer for a given entity config. It supports: * @@ -658,4 +678,5 @@ export default combineReducers( { autosaves, blockPatterns, blockPatternCategories, + userGlobalStyleVariations, } ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index b33bb42e653379..3a61e768a13336 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -487,17 +487,30 @@ export const __experimentalGetCurrentThemeBaseGlobalStyles = ); }; -export const __experimentalGetCurrentThemeGlobalStylesVariations = - () => +/** + * @param {string} [author] Variations author. Either 'theme' or 'user'. + */ +export const __experimentalGetGlobalStylesVariations = + ( author = 'theme' ) => async ( { resolveSelect, dispatch } ) => { const currentTheme = await resolveSelect.getCurrentTheme(); - const variations = await apiFetch( { - path: `/wp/v2/global-styles/themes/${ currentTheme.stylesheet }/variations`, - } ); - dispatch.__experimentalReceiveThemeGlobalStyleVariations( - currentTheme.stylesheet, - variations - ); + if ( author === 'theme' ) { + const variations = await apiFetch( { + path: `/wp/v2/global-styles/themes/${ currentTheme.stylesheet }/variations`, + } ); + dispatch.__experimentalReceiveThemeGlobalStyleVariations( + currentTheme.stylesheet, + variations + ); + } else { + const variations = await apiFetch( { + path: `/wp/v2/global-styles`, + } ); + dispatch.__experimentalReceiveUserGlobalStyleVariations( + currentTheme.stylesheet, + variations + ); + } }; export const getBlockPatterns = diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 9998d67728e745..fad741598315cb 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -34,7 +34,8 @@ export interface State { embedPreviews: Record< string, { html: string } >; entities: EntitiesState; themeBaseGlobalStyles: Record< string, Object >; - themeGlobalStyleVariations: Record< string, string >; + themeGlobalStyleVariations: Record< string, Object[] >; + userGlobalStyleVariations: Record< string, Object[] >; undo: UndoState; users: UserState; } @@ -1238,20 +1239,27 @@ export function __experimentalGetCurrentThemeBaseGlobalStyles( } /** - * Return the ID of the current global styles object. + * Return global styles variations. * * @param state Data state. + * @param author Variations author. Either 'theme' or 'user'. * - * @return The current global styles ID. + * @return Global styles variations */ -export function __experimentalGetCurrentThemeGlobalStylesVariations( - state: State -): string | null { +export function __experimentalGetGlobalStylesVariations( + state: State, + author: 'theme' | 'user' = 'theme' +): Object[] | null { const currentTheme = getCurrentTheme( state ); if ( ! currentTheme ) { return null; } - return state.themeGlobalStyleVariations[ currentTheme.stylesheet ]; + + if ( author === 'theme' ) { + return state.themeGlobalStyleVariations[ currentTheme.stylesheet ]; + } + + return state.userGlobalStyleVariations[ currentTheme.stylesheet ]; } /** diff --git a/packages/e2e-tests/plugins/global-styles.php b/packages/e2e-tests/plugins/global-styles.php new file mode 100644 index 00000000000000..5f050801a868d4 --- /dev/null +++ b/packages/e2e-tests/plugins/global-styles.php @@ -0,0 +1,32 @@ + WP_REST_Server::DELETABLE, + 'callback' => function() { + global $wpdb; + $gs_id = WP_Theme_JSON_Resolver_Gutenberg::get_user_global_styles_post_id(); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->get_results( "DELETE FROM {$wpdb->posts} WHERE post_type = 'wp_global_styles' AND id != {$gs_id}" ); + return rest_ensure_response( array( 'deleted' => true ) ); + }, + 'permission_callback' => '__return_true', + ), + ) + ); +} +add_action( 'rest_api_init', 'gutenberg_add_delete_all_global_styles_endpoint' ); diff --git a/packages/edit-site/src/components/global-styles/global-styles-provider.js b/packages/edit-site/src/components/global-styles/global-styles-provider.js index 0813dc63b2cb86..2cdd336869dfb3 100644 --- a/packages/edit-site/src/components/global-styles/global-styles-provider.js +++ b/packages/edit-site/src/components/global-styles/global-styles-provider.js @@ -6,7 +6,7 @@ import { mergeWith, isEmpty, mapValues } from 'lodash'; /** * WordPress dependencies */ -import { useMemo, useCallback } from '@wordpress/element'; +import { useMemo, useCallback, useState } from '@wordpress/element'; import { useSelect, useDispatch } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; @@ -15,6 +15,8 @@ import { store as coreStore } from '@wordpress/core-data'; */ import { GlobalStylesContext } from './context'; +/* eslint-disable dot-notation, camelcase */ + function mergeTreesCustomizer( _, srcValue ) { // We only pass as arrays the presets, // in which case we want the new array of values @@ -45,10 +47,15 @@ const cleanEmptyObject = ( object ) => { }; function useGlobalStylesUserConfig() { - const { globalStylesId, isReady, settings, styles } = useSelect( + const { hasFinishedResolution } = useSelect( coreStore ); + const { editEntityRecord, __experimentalDiscardRecordChanges } = + useDispatch( coreStore ); + + const [ isReady, setIsReady ] = useState( false ); + + const { globalStylesId, settings, styles, associated_style_id } = useSelect( ( select ) => { - const { getEditedEntityRecord, hasFinishedResolution } = - select( coreStore ); + const { getEditedEntityRecord } = select( coreStore ); const _globalStylesId = select( coreStore ).__experimentalGetCurrentGlobalStylesId(); const record = _globalStylesId @@ -58,40 +65,74 @@ function useGlobalStylesUserConfig() { _globalStylesId ) : undefined; + const _associatedStyleId = record + ? record[ 'associated_style_id' ] + : undefined; + if ( _associatedStyleId ) { + getEditedEntityRecord( + 'root', + 'globalStyles', + _associatedStyleId + ); + } let hasResolved = false; if ( + ! isReady && hasFinishedResolution( '__experimentalGetCurrentGlobalStylesId' ) ) { - hasResolved = _globalStylesId - ? hasFinishedResolution( 'getEditedEntityRecord', [ + hasResolved = ( () => { + if ( ! _globalStylesId ) { + return false; + } + + const userStyleFinishedResolution = hasFinishedResolution( + 'getEditedEntityRecord', + [ 'root', 'globalStyles', _globalStylesId ] + ); + + if ( ! _associatedStyleId ) { + return userStyleFinishedResolution; + } + + const associatedStyleFinishedResolution = + hasFinishedResolution( 'getEditedEntityRecord', [ 'root', 'globalStyles', - _globalStylesId, - ] ) - : true; + _associatedStyleId, + ] ); + + return ( + userStyleFinishedResolution && + associatedStyleFinishedResolution + ); + } )(); + + if ( hasResolved ) { + setIsReady( true ); + } } return { globalStylesId: _globalStylesId, - isReady: hasResolved, settings: record?.settings, styles: record?.styles, + associated_style_id: _associatedStyleId, }; }, [] ); const { getEditedEntityRecord } = useSelect( coreStore ); - const { editEntityRecord } = useDispatch( coreStore ); const config = useMemo( () => { return { settings: settings ?? {}, styles: styles ?? {}, + associated_style_id: associated_style_id ?? null, }; - }, [ settings, styles ] ); + }, [ settings, styles, associated_style_id ] ); const setConfig = useCallback( ( callback, options = {} ) => { @@ -103,18 +144,86 @@ function useGlobalStylesUserConfig() { const currentConfig = { styles: record?.styles ?? {}, settings: record?.settings ?? {}, + associated_style_id: record?.associated_style_id ?? 0, }; const updatedConfig = callback( currentConfig ); + const updatedRecord = { + styles: cleanEmptyObject( updatedConfig.styles ) || {}, + settings: cleanEmptyObject( updatedConfig.settings ) || {}, + associated_style_id: + updatedConfig[ 'associated_style_id' ] || 0, + }; + + let associatedStyleIdChanged = false; + + if ( + currentConfig[ 'associated_style_id' ] !== + updatedRecord[ 'associated_style_id' ] + ) { + associatedStyleIdChanged = true; + __experimentalDiscardRecordChanges( + 'root', + 'globalStyles', + currentConfig[ 'associated_style_id' ] + ); + } + editEntityRecord( 'root', 'globalStyles', globalStylesId, - { - styles: cleanEmptyObject( updatedConfig.styles ) || {}, - settings: cleanEmptyObject( updatedConfig.settings ) || {}, - }, + updatedRecord, options ); + + // Also add changes that were made to the user record to the associated record. + if ( + ! associatedStyleIdChanged && + updatedRecord[ 'associated_style_id' ] + ) { + if ( + ( ! hasFinishedResolution( 'getEditedEntityRecord' ), + [ + 'root', + 'globalStyles', + updatedRecord[ 'associated_style_id' ], + ] ) + ) { + const intervalId = setInterval( () => { + if ( + ( hasFinishedResolution( 'getEditedEntityRecord' ), + [ + 'root', + 'globalStyles', + updatedRecord[ 'associated_style_id' ], + ] ) + ) { + editEntityRecord( + 'root', + 'globalStyles', + updatedRecord[ 'associated_style_id' ], + { + settings: updatedRecord.settings, + styles: updatedRecord.styles, + }, + options + ); + clearInterval( intervalId ); + } + }, 500 ); + } else { + editEntityRecord( + 'root', + 'globalStyles', + updatedRecord[ 'associated_style_id' ], + { + settings: updatedRecord.settings, + styles: updatedRecord.styles, + }, + options + ); + } + } }, [ globalStylesId ] ); @@ -162,6 +271,8 @@ function useGlobalStylesContext() { return context; } +/* eslint-enable dot-notation, camelcase */ + export function GlobalStylesProvider( { children } ) { const context = useGlobalStylesContext(); if ( ! context.isReady ) { diff --git a/packages/edit-site/src/components/global-styles/hooks.js b/packages/edit-site/src/components/global-styles/hooks.js index c767f1a488cbfd..7d800551ac64e4 100644 --- a/packages/edit-site/src/components/global-styles/hooks.js +++ b/packages/edit-site/src/components/global-styles/hooks.js @@ -16,11 +16,17 @@ import { __EXPERIMENTAL_PATHS_WITH_MERGE as PATHS_WITH_MERGE, __EXPERIMENTAL_STYLE_PROPERTY as STYLE_PROPERTY, } from '@wordpress/blocks'; +import { store as coreStore, useEntityRecords } from '@wordpress/core-data'; +import { useSelect, useDispatch } from '@wordpress/data'; /** * Internal dependencies */ -import { getValueFromVariable, getPresetVariableFromValue } from './utils'; +import { + getValueFromVariable, + getPresetVariableFromValue, + compareVariations, +} from './utils'; import { GlobalStylesContext } from './context'; // Enable colord's a11y plugin. @@ -364,3 +370,77 @@ export function useColorRandomizer( name ) { ? [ randomizeColors ] : []; } + +export function useHasUserModifiedStyles() { + const { user } = useContext( GlobalStylesContext ); + return ( + Object.keys( user.settings ).length > 0 || + Object.keys( user.styles ).length > 0 + ); +} + +export function useCreateNewStyleRecord( title ) { + const { saveEntityRecord } = useDispatch( coreStore ); + const { user } = useContext( GlobalStylesContext ); + const callback = useCallback( () => { + const recordData = { + ...user, + title, + }; + /* eslint-disable dot-notation */ + delete recordData[ 'associated_style_id' ]; + delete recordData[ 'id' ]; + /* eslint-enable dot-notation */ + return saveEntityRecord( 'root', 'globalStyles', recordData ).then( + ( rawVariation ) => { + return { + ...rawVariation, + title: rawVariation?.title?.rendered, + }; + } + ); + }, [ title, user ] ); + return callback; +} + +export function useCustomSavedStyles() { + const { globalStylesId } = useSelect( ( select ) => { + return { + globalStylesId: + select( coreStore ).__experimentalGetCurrentGlobalStylesId(), + }; + }, [] ); + const { records: variations } = useEntityRecords( 'root', 'globalStyles' ); + + const customVariations = useMemo( () => { + return ( + variations + ?.filter( ( variation ) => variation.id !== globalStylesId ) + ?.map( ( variation ) => { + let newVariation = variation; + if ( variation?.title?.rendered !== undefined ) { + newVariation = { + ...variation, + title: variation.title.rendered, + }; + } + + return newVariation; + } ) || [] + ); + }, [ globalStylesId, variations ] ); + + return customVariations; +} + +export function useUserChangesMatchAnyVariation( variations ) { + const { user } = useContext( GlobalStylesContext ); + const matches = useMemo( + () => + variations?.some( ( variation ) => + compareVariations( user, variation ) + ), + [ user, variations ] + ); + return matches; +} diff --git a/packages/edit-site/src/components/global-styles/screen-root.js b/packages/edit-site/src/components/global-styles/screen-root.js index 0d7959f59fe95b..488e0fbd024750 100644 --- a/packages/edit-site/src/components/global-styles/screen-root.js +++ b/packages/edit-site/src/components/global-styles/screen-root.js @@ -29,9 +29,7 @@ function ScreenRoot() { const { variations } = useSelect( ( select ) => { return { variations: - select( - coreStore - ).__experimentalGetCurrentThemeGlobalStylesVariations(), + select( coreStore ).__experimentalGetGlobalStylesVariations(), }; }, [] ); diff --git a/packages/edit-site/src/components/global-styles/screen-style-variations.js b/packages/edit-site/src/components/global-styles/screen-style-variations.js index b88b81a0c08d17..810c736d3c49ba 100644 --- a/packages/edit-site/src/components/global-styles/screen-style-variations.js +++ b/packages/edit-site/src/components/global-styles/screen-style-variations.js @@ -2,7 +2,6 @@ * External dependencies */ import classnames from 'classnames'; -import fastDeepEqual from 'fast-deep-equal/es6'; /** * WordPress dependencies @@ -11,6 +10,7 @@ import { store as coreStore } from '@wordpress/core-data'; import { useSelect, useDispatch } from '@wordpress/data'; import { useMemo, + useCallback, useContext, useState, useEffect, @@ -18,12 +18,23 @@ import { } from '@wordpress/element'; import { ENTER } from '@wordpress/keycodes'; import { - __experimentalGrid as Grid, Card, CardBody, + CardDivider, + Button, + Modal, + MenuGroup, + MenuItem, + __experimentalHeading as Heading, + __experimentalText as Text, + __experimentalHStack as HStack, + __experimentalGrid as Grid, + __experimentalInputControl as InputControl, } from '@wordpress/components'; +import { plus } from '@wordpress/icons'; import { __ } from '@wordpress/i18n'; import { store as blockEditorStore } from '@wordpress/block-editor'; +import { MoreMenuDropdown } from '@wordpress/interface'; /** * Internal dependencies @@ -32,17 +43,30 @@ import { mergeBaseAndUserConfigs } from './global-styles-provider'; import { GlobalStylesContext } from './context'; import StylesPreview from './preview'; import ScreenHeader from './header'; +import { + useHasUserModifiedStyles, + useCreateNewStyleRecord, + useCustomSavedStyles, + useUserChangesMatchAnyVariation, +} from './hooks'; +import { compareVariations } from './utils'; -function compareVariations( a, b ) { - return ( - fastDeepEqual( a.styles, b.styles ) && - fastDeepEqual( a.settings, b.settings ) - ); -} +/* eslint-disable dot-notation */ -function Variation( { variation } ) { +function Variation( { variation, userChangesMatchAnyVariation } ) { const [ isFocused, setIsFocused ] = useState( false ); const { base, user, setUserConfig } = useContext( GlobalStylesContext ); + const { hasEditsForEntityRecord } = useSelect( coreStore ); + const { globalStyleId } = useSelect( ( select ) => { + return { + globalStyleId: + select( coreStore ).__experimentalGetCurrentGlobalStylesId(), + }; + }, [] ); + + // StylesPreview needs to be wrapped in a custom context so that the styles + // appear correctly. Otherwise, they would be overriden by current user + // settings. const context = useMemo( () => { return { user: { @@ -56,10 +80,25 @@ function Variation( { variation } ) { }, [ variation, base ] ); const selectVariation = () => { + /* eslint-disable no-alert */ + if ( + ! userChangesMatchAnyVariation && + hasEditsForEntityRecord( 'root', 'globalStyles', globalStyleId ) && + ! window.confirm( + __( + 'Are you sure you want to switch to this variation? Unsaved changes will be lost.' + ) + ) + ) { + return; + } + /* eslint-enable no-alert */ + setUserConfig( () => { return { settings: variation.settings, styles: variation.styles, + associated_style_id: 0, }; } ); }; @@ -76,42 +115,175 @@ function Variation( { variation } ) { }, [ user, variation ] ); return ( - -
setIsFocused( true ) } - onBlur={ () => setIsFocused( false ) } - > -
+
setIsFocused( true ) } + onBlur={ () => setIsFocused( false ) } + > +
+ -
+
- +
+ ); +} + +function UserVariation( { variation, userChangesMatchAnyVariation } ) { + const [ isFocused, setIsFocused ] = useState( false ); + const { base, user, setUserConfig } = useContext( GlobalStylesContext ); + const associatedStyleId = user[ 'associated_style_id' ]; + const { hasEditsForEntityRecord } = useSelect( coreStore ); + const { deleteEntityRecord } = useDispatch( coreStore ); + const { globalStyleId } = useSelect( ( select ) => { + return { + globalStyleId: + select( coreStore ).__experimentalGetCurrentGlobalStylesId(), + }; + }, [] ); + + const isMoreMenuClick = useCallback( ( e ) => { + if ( + e.target.closest( '.components-dropdown-menu__toggle' ) || + e.target.closest( '.components-menu-item__button' ) + ) { + return true; + } + + return false; + }, [] ); + + // StylesPreview needs to be wrapped in a custom context so that the styles + // appear correctly. Otherwise, they would be overriden by current user + // settings. + const context = useMemo( () => { + return { + user: { + settings: variation.settings ?? {}, + styles: variation.styles ?? {}, + }, + base, + merged: mergeBaseAndUserConfigs( base, variation ), + setUserConfig: () => {}, + }; + }, [ variation, base ] ); + + const isActive = useMemo( + () => variation.id === associatedStyleId, + [ variation, associatedStyleId ] + ); + + const selectVariation = useCallback( + ( e ) => { + if ( isMoreMenuClick( e ) ) { + return; + } + + /* eslint-disable no-alert */ + if ( + ! userChangesMatchAnyVariation && + hasEditsForEntityRecord( + 'root', + 'globalStyles', + globalStyleId + ) && + ! window.confirm( + __( + 'Are you sure you want to switch to this variation? Unsaved changes will be lost.' + ) + ) + ) { + return; + } + /* eslint-enable no-alert */ + + setUserConfig( () => ( { + settings: variation.settings, + styles: variation.styles, + associated_style_id: variation.id, + } ) ); + }, + [ variation, globalStyleId, userChangesMatchAnyVariation ] + ); + + const selectOnEnter = ( event ) => { + if ( event.keyCode === ENTER ) { + event.preventDefault(); + selectVariation(); + } + }; + + const deleteStyleHandler = useCallback( () => { + // If this is the associated variation, remove the association + if ( associatedStyleId === variation.id ) { + setUserConfig( ( currentConfig ) => ( { + ...currentConfig, + associated_style_id: 0, + } ) ); + } + + deleteEntityRecord( 'root', 'globalStyles', variation.id ); + }, [ variation, associatedStyleId ] ); + + return ( +
setIsFocused( true ) } + onBlur={ () => setIsFocused( false ) } + > +
+ + + +
+ + { () => ( + + + { __( 'Delete style' ) } + + + ) } + +
); } function ScreenStyleVariations() { + const [ createNewVariationModalOpen, setCreateNewVariationModalOpen ] = + useState( false ); + const [ newStyleName, setNewStyleName ] = useState( '' ); + const [ isStyleRecordSaving, setIsStyleRecordSaving ] = useState( false ); + const { setUserConfig } = useContext( GlobalStylesContext ); + const { variations, mode } = useSelect( ( select ) => { return { variations: - select( - coreStore - ).__experimentalGetCurrentThemeGlobalStylesVariations(), + select( coreStore ).__experimentalGetGlobalStylesVariations(), mode: select( blockEditorStore ).__unstableGetEditorMode(), }; @@ -132,6 +304,22 @@ function ScreenStyleVariations() { ]; }, [ variations ] ); + const hasUserModifiedStyles = useHasUserModifiedStyles(); + const userVariations = useCustomSavedStyles(); + const allVariations = useMemo( () => { + const ret = []; + if ( Array.isArray( withEmptyVariation ) ) { + ret.push( ...withEmptyVariation ); + } + if ( Array.isArray( userVariations ) ) { + ret.push( ...userVariations ); + } + return ret; + }, [ withEmptyVariation, userVariations ] ); + + const userChangesMatchAnyVariation = + useUserChangesMatchAnyVariation( allVariations ); + const { __unstableSetEditorMode } = useDispatch( blockEditorStore ); const shouldRevertInitialMode = useRef( null ); useEffect( () => { @@ -157,6 +345,8 @@ function ScreenStyleVariations() { } }, [] ); + const createNewStyleRecord = useCreateNewStyleRecord( newStyleName ); + return ( <> + + + + { __( 'Custom styles' ) } + +
+ + ) } ); } +/* eslint-enable dot-notation */ + export default ScreenStyleVariations; diff --git a/packages/edit-site/src/components/global-styles/style.scss b/packages/edit-site/src/components/global-styles/style.scss index 5616a068b594c8..03c9c7c6e9f5c2 100644 --- a/packages/edit-site/src/components/global-styles/style.scss +++ b/packages/edit-site/src/components/global-styles/style.scss @@ -90,6 +90,8 @@ $block-preview-height: 150px; .edit-site-global-styles-variations_item { box-sizing: border-box; + display: flex; + flex-direction: column; .edit-site-global-styles-variations_item-preview { padding: $border-width * 2; @@ -108,6 +110,16 @@ $block-preview-height: 150px; &:focus .edit-site-global-styles-variations_item-preview { border: var(--wp-admin-theme-color) var(--wp-admin-border-width-focus) solid; } + + .components-dropdown { + margin-left: auto; + } + + .components-dropdown-menu__toggle { + > svg { + transform: rotate(90deg); + } + } } .edit-site-global-styles-icon-with-current-color { @@ -146,3 +158,21 @@ $block-preview-height: 150px; max-height: 200px; overflow-y: scroll; } + +.edit-site-global-styles__cs { + margin-bottom: 0.5em; + + .components-heading { + margin: 0 !important; + } +} + +.edit-site-global-styles__cs-content { + display: flex; + gap: 2em; + flex-direction: column; + + .components-button { + margin-left: auto; + } +} diff --git a/packages/edit-site/src/components/global-styles/utils.js b/packages/edit-site/src/components/global-styles/utils.js index 14f3b868294172..a1b3f9c21576bc 100644 --- a/packages/edit-site/src/components/global-styles/utils.js +++ b/packages/edit-site/src/components/global-styles/utils.js @@ -2,6 +2,7 @@ * External dependencies */ import { get } from 'lodash'; +import fastDeepEqual from 'fast-deep-equal/es6'; /** * Internal dependencies @@ -333,3 +334,26 @@ export function scopeSelector( scope, selector ) { return selectorsScoped.join( ', ' ); } + +export function compareVariations( a, b ) { + if ( ! a.styles ) { + a.styles = {}; + } + + if ( ! a.settings ) { + a.settings = {}; + } + + if ( ! b.styles ) { + b.styles = {}; + } + + if ( ! b.settings ) { + b.settings = {}; + } + + return ( + fastDeepEqual( a.styles, b.styles ) && + fastDeepEqual( a.settings, b.settings ) + ); +} diff --git a/test/e2e/specs/site-editor/style-variations.spec.js b/test/e2e/specs/site-editor/style-variations.spec.js index 925be3780c8fdc..b006c57ddc1952 100644 --- a/test/e2e/specs/site-editor/style-variations.spec.js +++ b/test/e2e/specs/site-editor/style-variations.spec.js @@ -4,8 +4,8 @@ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); test.use( { - siteEditorStyleVariations: async ( { page }, use ) => { - await use( new SiteEditorStyleVariations( { page } ) ); + siteEditorStyleVariations: async ( { page, requestUtils }, use ) => { + await use( new SiteEditorStyleVariations( { page, requestUtils } ) ); }, } ); @@ -14,19 +14,34 @@ test.describe( 'Global styles variations', () => { await requestUtils.activateTheme( 'gutenberg-test-themes/style-variations' ); + await requestUtils.activatePlugin( + 'gutenberg-test-plugin-global-styles' + ); await requestUtils.deleteAllTemplates( 'wp_template' ); await requestUtils.deleteAllTemplates( 'wp_template_part' ); + // Delete all user global styles + await requestUtils.rest( { + method: 'DELETE', + path: '/wp/v2/delete-all-global-styles', + } ); } ); test.afterEach( async ( { requestUtils } ) => { await Promise.all( [ requestUtils.deleteAllTemplates( 'wp_template' ), requestUtils.deleteAllTemplates( 'wp_template_part' ), + requestUtils.rest( { + method: 'DELETE', + path: '/wp/v2/delete-all-global-styles', + } ), ] ); } ); test.afterAll( async ( { requestUtils } ) => { await requestUtils.activateTheme( 'twentytwentyone' ); + await requestUtils.deactivatePlugin( + 'gutenberg-test-plugin-global-styles' + ); } ); test( 'should have three variations available with the first one being active', async ( { @@ -210,6 +225,106 @@ test.describe( 'Global styles variations', () => { 'rgb(255, 239, 11)' ); } ); + + test( 'can create custom style variations', async ( { + admin, + page, + siteEditorStyleVariations, + siteEditor, + } ) => { + await admin.visitSiteEditor( { + postId: 'gutenberg-test-themes/style-variations//index', + postType: 'wp_template', + } ); + await siteEditor.enterEditMode(); + + // Assert no custom styles yet. + await siteEditorStyleVariations.browseStyles(); + await page.getByText( 'No custom styles yet.' ).click(); + + // Change background color + await page + .getByRole( 'button', { name: 'Navigate to the previous view' } ) + .click(); + await page.getByRole( 'button', { name: 'Colors styles' } ).click(); + await page + .getByRole( 'button', { name: 'Colors background styles' } ) + .click(); + await page + .getByRole( 'button', { name: 'Color: Cyan bluish gray' } ) + .click(); + + // Create new style + await page + .getByRole( 'button', { name: 'Navigate to the previous view' } ) + .click(); + await page + .getByRole( 'button', { name: 'Navigate to the previous view' } ) + .click(); + await page.getByRole( 'button', { name: 'Browse styles' } ).click(); + await page + .locator( + '.components-card__body > .components-flex > .components-button' + ) + .click(); + await page.getByLabel( 'Style name' ).click(); + await page.getByLabel( 'Style name' ).fill( 'My custom style' ); + await page.getByRole( 'button', { name: 'Create' } ).click(); + + // Check that the new style exists + await page.getByRole( 'button', { name: 'My custom style' } ).click(); + } ); + + test( 'can delete custom style variations', async ( { + admin, + page, + siteEditorStyleVariations, + siteEditor, + } ) => { + await admin.visitSiteEditor( { + postId: 'gutenberg-test-themes/style-variations//index', + postType: 'wp_template', + } ); + await siteEditor.enterEditMode(); + await siteEditorStyleVariations.disableWelcomeGuide(); + + // Change background color + await page.getByRole( 'button', { name: 'Styles' } ).click(); + await page.getByRole( 'button', { name: 'Colors styles' } ).click(); + await page + .getByRole( 'button', { name: 'Colors background styles' } ) + .click(); + await page + .getByRole( 'button', { name: 'Color: Cyan bluish gray' } ) + .click(); + + // Create new style + await page + .getByRole( 'button', { name: 'Navigate to the previous view' } ) + .click(); + await page + .getByRole( 'button', { name: 'Navigate to the previous view' } ) + .click(); + await page.getByRole( 'button', { name: 'Browse styles' } ).click(); + await page + .locator( + '.components-card__body > .components-flex > .components-button' + ) + .click(); + await page.getByLabel( 'Style name' ).click(); + await page.getByLabel( 'Style name' ).fill( 'My custom style' ); + await page.getByRole( 'button', { name: 'Create' } ).click(); + + // Delete the style + await page + .getByRole( 'button', { name: 'My custom style' } ) + .getByRole( 'button', { name: 'Options' } ) + .click(); + await page.getByRole( 'menuitem', { name: 'Delete style' } ).click(); + + // Check that there are no custom styles + await page.getByText( 'No custom styles yet.' ).click(); + } ); } ); class SiteEditorStyleVariations {