diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index e8daf6e34e9782..1ee04e09550e2d 100644 --- a/docs/reference-guides/data/data-core.md +++ b/docs/reference-guides/data/data-core.md @@ -126,6 +126,18 @@ _Returns_ - `any`: The current theme. +### getCurrentThemeGlobalStylesRevisions + +Returns the revisions of the current global styles theme. + +_Parameters_ + +- _state_ `State`: Data state. + +_Returns_ + +- `Object | null`: The current global styles. + ### getCurrentUser Returns the current user. diff --git a/package-lock.json b/package-lock.json index 1553d82a365d5b..24e57ea2f08f85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17412,6 +17412,7 @@ "@wordpress/core-commands": "file:packages/core-commands", "@wordpress/core-data": "file:packages/core-data", "@wordpress/data": "file:packages/data", + "@wordpress/date": "file:packages/date", "@wordpress/deprecated": "file:packages/deprecated", "@wordpress/dom": "file:packages/dom", "@wordpress/editor": "file:packages/editor", diff --git a/packages/block-editor/src/components/global-styles/color-panel.js b/packages/block-editor/src/components/global-styles/color-panel.js index b5f73f2340f64f..a24a87e7d9a207 100644 --- a/packages/block-editor/src/components/global-styles/color-panel.js +++ b/packages/block-editor/src/components/global-styles/color-panel.js @@ -20,7 +20,7 @@ import { Button, } from '@wordpress/components'; import { useCallback } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies @@ -230,6 +230,11 @@ function ColorPanelDropdown( { { 'is-open': isOpen } ), 'aria-expanded': isOpen, + 'aria-label': sprintf( + /* translators: %s is the type of color property, e.g., "background" */ + __( 'Color %s styles' ), + label + ), }; return ( diff --git a/packages/block-editor/src/components/global-styles/index.js b/packages/block-editor/src/components/global-styles/index.js index 7e41c11c507e78..2eb6a0f3287e56 100644 --- a/packages/block-editor/src/components/global-styles/index.js +++ b/packages/block-editor/src/components/global-styles/index.js @@ -5,7 +5,10 @@ export { useSettingsForBlockElement, } from './hooks'; export { getBlockCSSSelector } from './get-block-css-selector'; -export { useGlobalStylesOutput } from './use-global-styles-output'; +export { + useGlobalStylesOutput, + useGlobalStylesOutputWithConfig, +} from './use-global-styles-output'; export { GlobalStylesContext } from './context'; export { default as TypographyPanel, @@ -20,3 +23,4 @@ export { default as ColorPanel, useHasColorPanel } from './color-panel'; export { default as EffectsPanel, useHasEffectsPanel } from './effects-panel'; export { default as FiltersPanel, useHasFiltersPanel } from './filters-panel'; export { default as AdvancedPanel } from './advanced-panel'; +export { areGlobalStyleConfigsEqual } from './utils'; diff --git a/packages/block-editor/src/components/global-styles/test/utils.js b/packages/block-editor/src/components/global-styles/test/utils.js index 7d0e3464557f61..0bca8da6f7da96 100644 --- a/packages/block-editor/src/components/global-styles/test/utils.js +++ b/packages/block-editor/src/components/global-styles/test/utils.js @@ -1,7 +1,11 @@ /** * Internal dependencies */ -import { getPresetVariableFromValue, getValueFromVariable } from '../utils'; +import { + areGlobalStyleConfigsEqual, + getPresetVariableFromValue, + getValueFromVariable, +} from '../utils'; describe( 'editor utils', () => { const themeJson = { @@ -203,4 +207,56 @@ describe( 'editor utils', () => { } ); } ); } ); + + describe( 'areGlobalStyleConfigsEqual', () => { + test.each( [ + { original: null, variation: null, expected: true }, + { original: {}, variation: {}, expected: true }, + { original: {}, variation: undefined, expected: false }, + { + original: { + styles: { + color: { text: 'var(--wp--preset--color--red)' }, + }, + }, + variation: { + styles: { + color: { text: 'var(--wp--preset--color--blue)' }, + }, + }, + expected: false, + }, + { original: {}, variation: undefined, expected: false }, + { + original: { + styles: { + color: { text: 'var(--wp--preset--color--red)' }, + }, + settings: { + typography: { + fontSize: true, + }, + }, + }, + variation: { + styles: { + color: { text: 'var(--wp--preset--color--red)' }, + }, + settings: { + typography: { + fontSize: true, + }, + }, + }, + expected: true, + }, + ] )( + '.areGlobalStyleConfigsEqual( $original, $variation )', + ( { original, variation, expected } ) => { + expect( + areGlobalStyleConfigsEqual( original, variation ) + ).toBe( expected ); + } + ); + } ); } ); diff --git a/packages/block-editor/src/components/global-styles/use-global-styles-output.js b/packages/block-editor/src/components/global-styles/use-global-styles-output.js index 8777869d43621c..55dbab06de1529 100644 --- a/packages/block-editor/src/components/global-styles/use-global-styles-output.js +++ b/packages/block-editor/src/components/global-styles/use-global-styles-output.js @@ -1106,9 +1106,17 @@ const processCSSNesting = ( css, blockSelector ) => { return processedCSS; }; -export function useGlobalStylesOutput() { - let { merged: mergedConfig } = useContext( GlobalStylesContext ); - +/** + * Returns the global styles output using a global styles configuration. + * If wishing to generate global styles and settings based on the + * global styles config loaded in the editor context, use `useGlobalStylesOutput()`. + * The use case for a custom config is to generate bespoke styles + * and settings for previews, or other out-of-editor experiences. + * + * @param {Object} mergedConfig Global styles configuration. + * @return {Array} Array of stylesheets and settings. + */ +export function useGlobalStylesOutputWithConfig( mergedConfig = {} ) { const [ blockGap ] = useGlobalSetting( 'spacing.blockGap' ); const hasBlockGapSupport = blockGap !== null; const hasFallbackGapSupport = ! hasBlockGapSupport; // This setting isn't useful yet: it exists as a placeholder for a future explicit fallback styles support. @@ -1190,3 +1198,13 @@ export function useGlobalStylesOutput() { disableLayoutStyles, ] ); } + +/** + * Returns the global styles output based on the current state of global styles config loaded in the editor context. + * + * @return {Array} Array of stylesheets and settings. + */ +export function useGlobalStylesOutput() { + const { merged: mergedConfig } = useContext( GlobalStylesContext ); + return useGlobalStylesOutputWithConfig( mergedConfig ); +} diff --git a/packages/block-editor/src/components/global-styles/utils.js b/packages/block-editor/src/components/global-styles/utils.js index d1abd0a57dbc2d..0207110ae52234 100644 --- a/packages/block-editor/src/components/global-styles/utils.js +++ b/packages/block-editor/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 @@ -376,3 +377,29 @@ export function scopeSelector( scope, selector ) { return selectorsScoped.join( ', ' ); } + +/** + * Compares global style variations according to their styles and settings properties. + * + * @example + * ```js + * const globalStyles = { styles: { typography: { fontSize: '10px' } }, settings: {} }; + * const variation = { styles: { typography: { fontSize: '10000px' } }, settings: {} }; + * const isEqual = areGlobalStyleConfigsEqual( globalStyles, variation ); + * // false + * ``` + * + * @param {Object} original A global styles object. + * @param {Object} variation A global styles object. + * + * @return {boolean} Whether `original` and `variation` match. + */ +export function areGlobalStyleConfigsEqual( original, variation ) { + if ( typeof original !== 'object' || typeof variation !== 'object' ) { + return original === variation; + } + return ( + fastDeepEqual( original?.styles, variation?.styles ) && + fastDeepEqual( original?.settings, variation?.settings ) + ); +} diff --git a/packages/core-data/README.md b/packages/core-data/README.md index 93001a45334bf2..dddc3550e03b26 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -303,6 +303,18 @@ _Returns_ - `any`: The current theme. +### getCurrentThemeGlobalStylesRevisions + +Returns the revisions of the current global styles theme. + +_Parameters_ + +- _state_ `State`: Data state. + +_Returns_ + +- `Object | null`: The current global styles. + ### getCurrentUser Returns the current user. diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index bd6839c8b8afb2..ffae417a83cd13 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -210,6 +210,25 @@ export function receiveThemeSupports() { }; } +/** + * Returns an action object used in signalling that the theme global styles CPT post revisions have been received. + * Ignored from documentation as it's internal to the data store. + * + * @ignore + * + * @param {number} currentId The post id. + * @param {Array} revisions The global styles revisions. + * + * @return {Object} Action object. + */ +export function receiveThemeGlobalStyleRevisions( currentId, revisions ) { + return { + type: 'RECEIVE_THEME_GLOBAL_STYLE_REVISIONS', + currentId, + revisions, + }; +} + /** * Returns an action object used in signalling that the preview data for * a given URl has been received. diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 28ef468a487e71..f04d543919b8c8 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -651,6 +651,26 @@ export function navigationFallbackId( state = null, action ) { return state; } +/** + * Reducer managing the theme global styles revisions. + * + * @param {Record} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Record} Updated state. + */ +export function themeGlobalStyleRevisions( state = {}, action ) { + switch ( action.type ) { + case 'RECEIVE_THEME_GLOBAL_STYLE_REVISIONS': + return { + ...state, + [ action.currentId ]: action.revisions, + }; + } + + return state; +} + export default combineReducers( { terms, users, @@ -659,6 +679,7 @@ export default combineReducers( { currentUser, themeGlobalStyleVariations, themeBaseGlobalStyles, + themeGlobalStyleRevisions, taxonomies, entities, undo, diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 41f95b7d357c17..6437b759976901 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -180,7 +180,7 @@ export const getEntityRecords = let records = Object.values( await apiFetch( { path } ) ); // If we request fields but the result doesn't contain the fields, - // explicitely set these fields as "undefined" + // explicitly set these fields as "undefined" // that way we consider the query "fullfilled". if ( query._fields ) { records = records.map( ( record ) => { @@ -500,6 +500,51 @@ export const __experimentalGetCurrentThemeGlobalStylesVariations = ); }; +/** + * Fetches and returns the revisions of the current global styles theme. + */ +export const getCurrentThemeGlobalStylesRevisions = + () => + async ( { resolveSelect, dispatch } ) => { + const globalStylesId = + await resolveSelect.__experimentalGetCurrentGlobalStylesId(); + const record = globalStylesId + ? await resolveSelect.getEntityRecord( + 'root', + 'globalStyles', + globalStylesId + ) + : undefined; + const revisionsURL = record?._links?.[ 'version-history' ]?.[ 0 ]?.href; + + if ( revisionsURL ) { + const resetRevisions = await apiFetch( { + url: revisionsURL, + } ); + const revisions = resetRevisions?.map( ( revision ) => + Object.fromEntries( + Object.entries( revision ).map( ( [ key, value ] ) => [ + camelCase( key ), + value, + ] ) + ) + ); + dispatch.receiveThemeGlobalStyleRevisions( + globalStylesId, + revisions + ); + } + }; + +getCurrentThemeGlobalStylesRevisions.shouldInvalidate = ( action ) => { + return ( + action.type === 'SAVE_ENTITY_RECORD_FINISH' && + action.kind === 'root' && + ! action.error && + action.name === 'globalStyles' + ); +}; + export const getBlockPatterns = () => async ( { dispatch } ) => { diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index ebf803c8055ab6..7513d918109673 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -38,6 +38,7 @@ export interface State { entities: EntitiesState; themeBaseGlobalStyles: Record< string, Object >; themeGlobalStyleVariations: Record< string, string >; + themeGlobalStyleRevisions: Record< number, Object >; undo: UndoState; userPermissions: Record< string, boolean >; users: UserState; @@ -1247,3 +1248,23 @@ export function getNavigationFallbackId( ): EntityRecordKey | undefined { return state.navigationFallbackId; } + +/** + * Returns the revisions of the current global styles theme. + * + * @param state Data state. + * + * @return The current global styles. + */ +export function getCurrentThemeGlobalStylesRevisions( + state: State +): Object | null { + const currentGlobalStylesId = + __experimentalGetCurrentGlobalStylesId( state ); + + if ( ! currentGlobalStylesId ) { + return null; + } + + return state.themeGlobalStyleRevisions[ currentGlobalStylesId ]; +} diff --git a/packages/date/README.md b/packages/date/README.md index 7cd3116c73c4c8..8edd4e94a8538f 100644 --- a/packages/date/README.md +++ b/packages/date/README.md @@ -115,6 +115,19 @@ _Returns_ - `string`: Formatted date. +### humanTimeDiff + +Returns a human-readable time difference between two dates, like human_time_diff() in PHP. + +_Parameters_ + +- _from_ `Moment | Date | string`: From date, in the WP timezone. +- _to_ `Moment | Date | string | undefined`: To date, formatted in the WP timezone. + +_Returns_ + +- `string`: Human-readable time difference. + ### isInTheFuture Check whether a date is considered in the future according to the WordPress settings. diff --git a/packages/date/src/index.js b/packages/date/src/index.js index 2417d1b5edf853..9ac47f3a0a5f70 100644 --- a/packages/date/src/index.js +++ b/packages/date/src/index.js @@ -581,6 +581,20 @@ export function getDate( dateString ) { return momentLib.tz( dateString, WP_ZONE ).toDate(); } +/** + * Returns a human-readable time difference between two dates, like human_time_diff() in PHP. + * + * @param {Moment | Date | string} from From date, in the WP timezone. + * @param {Moment | Date | string | undefined} to To date, formatted in the WP timezone. + * + * @return {string} Human-readable time difference. + */ +export function humanTimeDiff( from, to ) { + const fromMoment = momentLib.tz( from, WP_ZONE ); + const toMoment = to ? momentLib.tz( to, WP_ZONE ) : momentLib.tz( WP_ZONE ); + return fromMoment.from( toMoment ); +} + /** * Creates a moment instance using the given timezone or, if none is provided, using global settings. * diff --git a/packages/date/src/test/index.js b/packages/date/src/test/index.js index 36414949af16a8..ff82748e02f23a 100644 --- a/packages/date/src/test/index.js +++ b/packages/date/src/test/index.js @@ -10,6 +10,7 @@ import { gmdateI18n, isInTheFuture, setSettings, + humanTimeDiff, } from '../'; describe( 'isInTheFuture', () => { @@ -620,4 +621,27 @@ describe( 'Moment.js Localization', () => { // Restore default settings. setSettings( settings ); } ); + + describe( 'humanTimeDiff', () => { + it( 'should return human readable time differences', () => { + expect( + humanTimeDiff( + '2023-04-28T11:00:00.000Z', + '2023-04-28T12:00:00.000Z' + ) + ).toBe( 'an hour ago' ); + expect( + humanTimeDiff( + '2023-04-28T11:00:00.000Z', + '2023-04-28T13:00:00.000Z' + ) + ).toBe( '2 hours ago' ); + expect( + humanTimeDiff( + '2023-04-28T11:00:00.000Z', + '2023-04-30T13:00:00.000Z' + ) + ).toBe( '2 days ago' ); + } ); + } ); } ); diff --git a/packages/e2e-test-utils-playwright/src/request-utils/index.ts b/packages/e2e-test-utils-playwright/src/request-utils/index.ts index e05e59bacc673f..99a11bf9956791 100644 --- a/packages/e2e-test-utils-playwright/src/request-utils/index.ts +++ b/packages/e2e-test-utils-playwright/src/request-utils/index.ts @@ -17,7 +17,11 @@ import { createUser, deleteAllUsers } from './users'; import { setupRest, rest, getMaxBatchSize, batchRest } from './rest'; import { getPluginsMap, activatePlugin, deactivatePlugin } from './plugins'; import { deleteAllTemplates } from './templates'; -import { activateTheme } from './themes'; +import { + activateTheme, + getCurrentThemeGlobalStylesPostId, + getThemeGlobalStylesRevisions, +} from './themes'; import { deleteAllBlocks } from './blocks'; import { createComment, deleteAllComments } from './comments'; import { createPost, deleteAllPosts } from './posts'; @@ -188,6 +192,12 @@ class RequestUtils { deleteAllPages: typeof deleteAllPages = deleteAllPages.bind( this ); /** @borrows createPage as this.createPage */ createPage: typeof createPage = createPage.bind( this ); + /** @borrows getCurrentThemeGlobalStylesPostId as this.getCurrentThemeGlobalStylesPostId */ + getCurrentThemeGlobalStylesPostId: typeof getCurrentThemeGlobalStylesPostId = + getCurrentThemeGlobalStylesPostId.bind( this ); + /** @borrows getThemeGlobalStylesRevisions as this.getThemeGlobalStylesRevisions */ + getThemeGlobalStylesRevisions: typeof getThemeGlobalStylesRevisions = + getThemeGlobalStylesRevisions.bind( this ); } export type { StorageState }; diff --git a/packages/e2e-test-utils-playwright/src/request-utils/themes.ts b/packages/e2e-test-utils-playwright/src/request-utils/themes.ts index 802530d947b0fc..6b4fd175887371 100644 --- a/packages/e2e-test-utils-playwright/src/request-utils/themes.ts +++ b/packages/e2e-test-utils-playwright/src/request-utils/themes.ts @@ -37,4 +37,54 @@ async function activateTheme( await response.dispose(); } -export { activateTheme }; +// https://developer.wordpress.org/rest-api/reference/themes/#definition +async function getCurrentThemeGlobalStylesPostId( this: RequestUtils ) { + type ThemeItem = { + stylesheet: string; + status: string; + _links: { 'wp:user-global-styles': { href: string }[] }; + }; + const themes = await this.rest< ThemeItem[] >( { + path: '/wp/v2/themes', + } ); + let themeGlobalStylesId: string = ''; + if ( themes && themes.length ) { + const currentTheme: ThemeItem | undefined = themes.find( + ( { status } ) => status === 'active' + ); + + const globalStylesURL = + currentTheme?._links?.[ 'wp:user-global-styles' ]?.[ 0 ]?.href; + if ( globalStylesURL ) { + themeGlobalStylesId = globalStylesURL?.split( + 'rest_route=/wp/v2/global-styles/' + )[ 1 ]; + } + } + return themeGlobalStylesId; +} + +/** + * Deletes all post revisions using the REST API. + * + * @param {} this RequestUtils. + * @param {string|number} parentId Post attributes. + */ +async function getThemeGlobalStylesRevisions( + this: RequestUtils, + parentId: number | string +) { + // Lists all global styles revisions. + return await this.rest< Record< string, Object >[] >( { + path: `/wp/v2/global-styles/${ parentId }/revisions`, + params: { + per_page: 100, + }, + } ); +} + +export { + activateTheme, + getCurrentThemeGlobalStylesPostId, + getThemeGlobalStylesRevisions, +}; diff --git a/packages/edit-site/package.json b/packages/edit-site/package.json index ba3586301830c1..c172941f7fe007 100644 --- a/packages/edit-site/package.json +++ b/packages/edit-site/package.json @@ -38,6 +38,7 @@ "@wordpress/core-commands": "file:../core-commands", "@wordpress/core-data": "file:../core-data", "@wordpress/data": "file:../data", + "@wordpress/date": "file:../date", "@wordpress/deprecated": "file:../deprecated", "@wordpress/dom": "file:../dom", "@wordpress/editor": "file:../editor", diff --git a/packages/edit-site/src/components/editor-canvas-container/index.js b/packages/edit-site/src/components/editor-canvas-container/index.js index c8fcca4a3a99c2..091c5b9ec60f62 100644 --- a/packages/edit-site/src/components/editor-canvas-container/index.js +++ b/packages/edit-site/src/components/editor-canvas-container/index.js @@ -30,6 +30,8 @@ function getEditorCanvasContainerTitle( view ) { switch ( view ) { case 'style-book': return __( 'Style Book' ); + case 'global-styles-revisions': + return __( 'Global styles revisions' ); default: return ''; } @@ -44,11 +46,7 @@ const { Fill: EditorCanvasContainerFill, } = createPrivateSlotFill( SLOT_FILL_NAME ); -function EditorCanvasContainer( { - children, - closeButtonLabel, - onClose = () => {}, -} ) { +function EditorCanvasContainer( { children, closeButtonLabel, onClose } ) { const editorCanvasContainerView = useSelect( ( select ) => unlock( select( editSiteStore ) ).getEditorCanvasContainerView(), @@ -65,7 +63,9 @@ function EditorCanvasContainer( { [ editorCanvasContainerView ] ); function onCloseContainer() { - onClose(); + if ( typeof onClose === 'function' ) { + onClose(); + } setEditorCanvasContainerView( undefined ); setIsClosed( true ); } @@ -93,22 +93,26 @@ function EditorCanvasContainer( { return null; } + const shouldShowCloseButton = onClose || closeButtonLabel; + return ( { /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */ }
-
diff --git a/packages/edit-site/src/components/editor/index.js b/packages/edit-site/src/components/editor/index.js index 62b62082e4b929..eb05e2526a55f9 100644 --- a/packages/edit-site/src/components/editor/index.js +++ b/packages/edit-site/src/components/editor/index.js @@ -37,6 +37,7 @@ import useTitle from '../routes/use-title'; import CanvasSpinner from '../canvas-spinner'; import { unlock } from '../../private-apis'; import useEditedEntityRecord from '../use-edited-entity-record'; +import { SidebarFixedBottomSlot } from '../sidebar-edit-mode/sidebar-fixed-bottom'; const interfaceLabels = { /* translators: accessibility text for the editor content landmark region. */ @@ -214,7 +215,10 @@ export default function Editor() { sidebar={ isEditMode && isRightSidebarOpen && ( - + <> + + + ) } footer={ diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/index.js b/packages/edit-site/src/components/global-styles/screen-revisions/index.js new file mode 100644 index 00000000000000..a0c7085b5f49f6 --- /dev/null +++ b/packages/edit-site/src/components/global-styles/screen-revisions/index.js @@ -0,0 +1,178 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + Button, + __experimentalUseNavigator as useNavigator, + __experimentalConfirmDialog as ConfirmDialog, + Spinner, +} from '@wordpress/components'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { useContext, useState, useEffect } from '@wordpress/element'; +import { + privateApis as blockEditorPrivateApis, + store as blockEditorStore, +} from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import ScreenHeader from '../header'; +import { unlock } from '../../../private-apis'; +import Revisions from '../../revisions'; +import SidebarFixedBottom from '../../sidebar-edit-mode/sidebar-fixed-bottom'; +import { store as editSiteStore } from '../../../store'; +import useGlobalStylesRevisions from './use-global-styles-revisions'; +import RevisionsButtons from './revisions-buttons'; + +const { GlobalStylesContext, areGlobalStyleConfigsEqual } = unlock( + blockEditorPrivateApis +); + +function ScreenRevisions() { + const { goBack } = useNavigator(); + const { user: userConfig, setUserConfig } = + useContext( GlobalStylesContext ); + const { blocks, editorCanvasContainerView } = useSelect( ( select ) => { + return { + editorCanvasContainerView: unlock( + select( editSiteStore ) + ).getEditorCanvasContainerView(), + blocks: select( blockEditorStore ).getBlocks(), + }; + }, [] ); + + const { revisions, isLoading, hasUnsavedChanges } = + useGlobalStylesRevisions(); + const [ globalStylesRevision, setGlobalStylesRevision ] = + useState( userConfig ); + + const [ currentRevisionId, setCurrentRevisionId ] = useState( + /* + * We need this for the first render, + * otherwise the unsaved changes haven't been merged into the revisions array yet. + */ + hasUnsavedChanges ? 'unsaved' : revisions?.[ 0 ]?.id + ); + const [ + isLoadingRevisionWithUnsavedChanges, + setIsLoadingRevisionWithUnsavedChanges, + ] = useState( false ); + const { setEditorCanvasContainerView } = unlock( + useDispatch( editSiteStore ) + ); + + useEffect( () => { + if ( editorCanvasContainerView !== 'global-styles-revisions' ) { + goBack(); + setEditorCanvasContainerView( editorCanvasContainerView ); + } + }, [ editorCanvasContainerView ] ); + + const onCloseRevisions = () => { + goBack(); + }; + + const restoreRevision = ( revision ) => { + setUserConfig( () => ( { + styles: revision?.styles, + settings: revision?.settings, + } ) ); + setIsLoadingRevisionWithUnsavedChanges( false ); + onCloseRevisions(); + }; + + const selectRevision = ( revision ) => { + setGlobalStylesRevision( { + styles: revision?.styles, + settings: revision?.settings, + id: revision?.id, + } ); + setCurrentRevisionId( revision?.id ); + }; + + const isLoadButtonEnabled = + !! globalStylesRevision?.id && + ! areGlobalStyleConfigsEqual( globalStylesRevision, userConfig ); + + return ( + <> + + { isLoading && ( + + ) } + { ! isLoading && ( + + ) } +
+ + { isLoadButtonEnabled && ( + + + + ) } +
+ { isLoadingRevisionWithUnsavedChanges && ( + restoreRevision( globalStylesRevision ) } + onCancel={ () => + setIsLoadingRevisionWithUnsavedChanges( false ) + } + > + <> +

+ { __( + 'Loading this revision will discard all unsaved changes.' + ) } +

+

+ { __( + 'Do you want to replace your unsaved changes in the editor?' + ) } +

+ +
+ ) } + + ); +} + +export default ScreenRevisions; diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/revisions-buttons.js b/packages/edit-site/src/components/global-styles/screen-revisions/revisions-buttons.js new file mode 100644 index 00000000000000..c4d624bf1727e2 --- /dev/null +++ b/packages/edit-site/src/components/global-styles/screen-revisions/revisions-buttons.js @@ -0,0 +1,131 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; +import { dateI18n, getDate, humanTimeDiff, getSettings } from '@wordpress/date'; + +/** + * Returns a button label for the revision. + * + * @param {Object} revision A revision object. + * @return {string} Translated label. + */ +function getRevisionLabel( revision ) { + const authorDisplayName = revision?.author?.name || __( 'User' ); + const isUnsaved = 'unsaved' === revision?.id; + + if ( isUnsaved ) { + return sprintf( + /* translators: %(name)s author display name */ + __( 'Unsaved changes by %(name)s' ), + { + name: authorDisplayName, + } + ); + } + const formattedDate = dateI18n( + getSettings().formats.datetimeAbbreviated, + getDate( revision?.modified ) + ); + + return revision?.isLatest + ? sprintf( + /* translators: %(name)s author display name, %(date)s: revision creation date */ + __( 'Changes saved by %(name)s on %(date)s (current)' ), + { + name: authorDisplayName, + date: formattedDate, + } + ) + : sprintf( + /* translators: %(name)s author display name, %(date)s: revision creation date */ + __( 'Changes saved by %(name)s on %(date)s' ), + { + name: authorDisplayName, + date: formattedDate, + } + ); +} + +/** + * Returns a rendered list of revisions buttons. + * + * @typedef {Object} props + * @property {Array} userRevisions A collection of user revisions. + * @property {number} currentRevisionId Callback fired when the modal is closed or action cancelled. + * @property {Function} onChange Callback fired when a revision is selected. + * + * @param {props} Component props. + * @return {JSX.Element} The modal component. + */ +function RevisionsButtons( { userRevisions, currentRevisionId, onChange } ) { + return ( +
    + { userRevisions.map( ( revision ) => { + const { id, author, isLatest, modified } = revision; + const authorDisplayName = author?.name || __( 'User' ); + const authorAvatar = author?.avatar_urls?.[ '48' ]; + /* + * If the currentId hasn't been selected yet, the first revision is + * the current one so long as the API returns revisions in descending order. + */ + const isActive = !! currentRevisionId + ? id === currentRevisionId + : isLatest; + + return ( +
  1. + +
  2. + ); + } ) } +
+ ); +} + +export default RevisionsButtons; diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/style.scss b/packages/edit-site/src/components/global-styles/screen-revisions/style.scss new file mode 100644 index 00000000000000..2a214663447a84 --- /dev/null +++ b/packages/edit-site/src/components/global-styles/screen-revisions/style.scss @@ -0,0 +1,99 @@ + +.edit-site-global-styles-screen-revisions { + margin: $grid-unit-20; +} + +.edit-site-global-styles-screen-revisions__revisions-list { + list-style: none; + margin: 0; + li { + margin-bottom: 0; + border-left: 1px solid $gray-300; + } +} + +.edit-site-global-styles-screen-revisions__revision-item { + position: relative; + padding: $grid-unit-10 0 $grid-unit-10 $grid-unit-15; + + &:first-child { + padding-top: 0; + } + + &:last-child { + padding-bottom: 0; + } + + &::before { + background: $gray-300; + border-radius: 50%; + content: "\a"; + display: inline-block; + height: $grid-unit-10; + width: $grid-unit-10; + position: absolute; + top: 50%; + left: 0; + transform: translate(-50%, -50%); + } + &.is-current::before { + background: var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba)); + } +} + +.edit-site-global-styles-screen-revisions__revision-button { + width: 100%; + height: auto; + display: block; + padding: $grid-unit-10 $grid-unit-15; + + &:hover { + background: rgba(var(--wp-admin-theme-color--rgb), 0.04); + + .edit-site-global-styles-screen-revisions__date { + color: var(--wp-admin-theme-color); + } + } +} + +.is-current { + .edit-site-global-styles-screen-revisions__revision-button { + color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba)); + opacity: 1; + background: rgba(var(--wp-admin-theme-color--rgb), 0.04); + } + + .edit-site-global-styles-screen-revisions__meta { + color: var(--wp-admin-theme-color); + } +} + +.edit-site-global-styles-screen-revisions__button { + justify-content: center; + width: 100%; +} + +.edit-site-global-styles-screen-revisions__description { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: $grid-unit-10; +} + +.edit-site-global-styles-screen-revisions__meta { + color: $gray-700; + display: flex; + justify-content: space-between; + width: 100%; + align-items: center; + + img { + width: $grid-unit-20; + height: $grid-unit-20; + border-radius: 100%; + } +} + +.edit-site-global-styles-screen-revisions__loading { + margin: $grid-unit-30 auto !important; +} diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/test/use-global-styles-revisions.js b/packages/edit-site/src/components/global-styles/screen-revisions/test/use-global-styles-revisions.js new file mode 100644 index 00000000000000..d1e36ec9e595f1 --- /dev/null +++ b/packages/edit-site/src/components/global-styles/screen-revisions/test/use-global-styles-revisions.js @@ -0,0 +1,125 @@ +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react'; + +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import useGlobalStylesRevisions from '../use-global-styles-revisions'; + +jest.mock( '@wordpress/data/src/components/use-select', () => jest.fn() ); + +jest.mock( '@wordpress/element', () => { + return { + __esModule: true, + ...jest.requireActual( '@wordpress/element' ), + useContext: jest.fn().mockImplementation( () => ( { + user: { + styles: 'ice-cream', + settings: 'cake', + }, + } ) ), + }; +} ); + +describe( 'useGlobalStylesRevisions', () => { + const selectValue = { + authors: [ + { + id: 4, + name: 'sam', + }, + ], + currentUser: { + name: 'fred', + avatar_urls: {}, + }, + isDirty: false, + revisions: [ + { + id: 1, + author: 4, + settings: {}, + styles: {}, + }, + ], + isLoading: false, + }; + + it( 'returns loaded revisions with no unsaved changes', () => { + useSelect.mockImplementation( () => selectValue ); + + const { result } = renderHook( () => useGlobalStylesRevisions() ); + const { revisions, isLoading, hasUnsavedChanges } = result.current; + + expect( isLoading ).toBe( false ); + expect( hasUnsavedChanges ).toBe( false ); + expect( revisions ).toEqual( [ + { + author: { + id: 4, + name: 'sam', + }, + id: 1, + isLatest: true, + settings: {}, + styles: {}, + }, + ] ); + } ); + + it( 'returns loaded revisions with saved changes', () => { + useSelect.mockImplementation( () => ( { + ...selectValue, + isDirty: true, + } ) ); + + const { result } = renderHook( () => useGlobalStylesRevisions() ); + const { revisions, isLoading, hasUnsavedChanges } = result.current; + + expect( isLoading ).toBe( false ); + expect( hasUnsavedChanges ).toBe( true ); + expect( revisions ).toEqual( [ + { + author: { + avatar_urls: {}, + name: 'fred', + }, + id: 'unsaved', + modified: revisions[ 0 ].modified, + settings: 'cake', + styles: 'ice-cream', + }, + { + author: { + id: 4, + name: 'sam', + }, + id: 1, + isLatest: true, + settings: {}, + styles: {}, + }, + ] ); + } ); + + it( 'returns empty revisions when still loading', () => { + useSelect.mockImplementation( () => ( { + ...selectValue, + isLoading: true, + } ) ); + + const { result } = renderHook( () => useGlobalStylesRevisions() ); + const { revisions, isLoading, hasUnsavedChanges } = result.current; + + expect( isLoading ).toBe( true ); + expect( hasUnsavedChanges ).toBe( false ); + expect( revisions ).toEqual( [] ); + } ); +} ); diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js b/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js new file mode 100644 index 00000000000000..e8f714ba72e9ba --- /dev/null +++ b/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js @@ -0,0 +1,103 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { useContext, useMemo } from '@wordpress/element'; +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; +/** + * External dependencies + */ +import { isEmpty } from 'lodash'; +/** + * Internal dependencies + */ +import { unlock } from '../../../private-apis'; + +const SITE_EDITOR_AUTHORS_QUERY = { + per_page: -1, + _fields: 'id,name,avatar_urls', + context: 'view', + capabilities: [ 'edit_theme_options' ], +}; + +const { GlobalStylesContext } = unlock( blockEditorPrivateApis ); +export default function useGlobalStylesRevisions() { + const { user: userConfig } = useContext( GlobalStylesContext ); + const { authors, currentUser, isDirty, revisions, isLoading } = useSelect( + ( select ) => { + const { + __experimentalGetDirtyEntityRecords, + getCurrentUser, + getUsers, + getCurrentThemeGlobalStylesRevisions, + isResolving, + } = select( coreStore ); + const dirtyEntityRecords = __experimentalGetDirtyEntityRecords(); + const _currentUser = getCurrentUser(); + const _isDirty = dirtyEntityRecords.length > 0; + const globalStylesRevisions = + getCurrentThemeGlobalStylesRevisions() || []; + const _authors = getUsers( SITE_EDITOR_AUTHORS_QUERY ); + + return { + authors: _authors, + currentUser: _currentUser, + isDirty: _isDirty, + revisions: globalStylesRevisions, + isLoading: + ! globalStylesRevisions.length || + isResolving( 'getUsers', [ SITE_EDITOR_AUTHORS_QUERY ] ), + }; + }, + [] + ); + return useMemo( () => { + let _modifiedRevisions = []; + if ( isLoading || ! revisions.length ) { + return { + revisions: _modifiedRevisions, + hasUnsavedChanges: isDirty, + isLoading, + }; + } + /* + * Adds a flag to the first revision, which is the latest. + * Also adds author information to the revision. + * Then, if there are unsaved changes in the editor, create a + * new "revision" item that represents the unsaved changes. + */ + _modifiedRevisions = revisions.map( ( revision ) => { + return { + ...revision, + author: authors.find( + ( author ) => author.id === revision.author + ), + }; + } ); + + if ( _modifiedRevisions[ 0 ]?.id !== 'unsaved' ) { + _modifiedRevisions[ 0 ].isLatest = true; + } + + if ( isDirty && ! isEmpty( userConfig ) && currentUser ) { + const unsavedRevision = { + id: 'unsaved', + styles: userConfig?.styles, + settings: userConfig?.settings, + author: { + name: currentUser?.name, + avatar_urls: currentUser?.avatar_urls, + }, + modified: new Date(), + }; + + _modifiedRevisions.unshift( unsavedRevision ); + } + return { + revisions: _modifiedRevisions, + hasUnsavedChanges: isDirty, + isLoading, + }; + }, [ revisions.length, isDirty, isLoading ] ); +} diff --git a/packages/edit-site/src/components/global-styles/style-variations-container.js b/packages/edit-site/src/components/global-styles/style-variations-container.js index 10d93a715405fa..074d86af4f80fb 100644 --- a/packages/edit-site/src/components/global-styles/style-variations-container.js +++ b/packages/edit-site/src/components/global-styles/style-variations-container.js @@ -2,7 +2,6 @@ * External dependencies */ import classnames from 'classnames'; -import fastDeepEqual from 'fast-deep-equal/es6'; /** * WordPress dependencies @@ -22,14 +21,9 @@ import { mergeBaseAndUserConfigs } from './global-styles-provider'; import StylesPreview from './preview'; import { unlock } from '../../private-apis'; -const { GlobalStylesContext } = unlock( blockEditorPrivateApis ); - -function compareVariations( a, b ) { - return ( - fastDeepEqual( a.styles, b.styles ) && - fastDeepEqual( a.settings, b.settings ) - ); -} +const { GlobalStylesContext, areGlobalStyleConfigsEqual } = unlock( + blockEditorPrivateApis +); function Variation( { variation } ) { const [ isFocused, setIsFocused ] = useState( false ); @@ -63,7 +57,7 @@ function Variation( { variation } ) { }; const isActive = useMemo( () => { - return compareVariations( user, variation ); + return areGlobalStyleConfigsEqual( user, variation ); }, [ user, variation ] ); return ( diff --git a/packages/edit-site/src/components/global-styles/ui.js b/packages/edit-site/src/components/global-styles/ui.js index ebc2ae164aa41b..133e8d61d11489 100644 --- a/packages/edit-site/src/components/global-styles/ui.js +++ b/packages/edit-site/src/components/global-styles/ui.js @@ -14,7 +14,7 @@ import { privateApis as blockEditorPrivateApis, store as blockEditorStore, } from '@wordpress/block-editor'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf, _n } from '@wordpress/i18n'; import { store as preferencesStore } from '@wordpress/preferences'; import { moreVertical } from '@wordpress/icons'; import { store as coreStore } from '@wordpress/core-data'; @@ -37,6 +37,7 @@ import ScreenLayout from './screen-layout'; import ScreenStyleVariations from './screen-style-variations'; import StyleBook from '../style-book'; import ScreenCSS from './screen-css'; +import ScreenRevisions from './screen-revisions'; import { unlock } from '../../private-apis'; import { store as editSiteStore } from '../../store'; @@ -46,7 +47,7 @@ const { Slot: GlobalStylesMenuSlot, Fill: GlobalStylesMenuFill } = function GlobalStylesActionMenu() { const { toggle } = useDispatch( preferencesStore ); - const { canEditCSS } = useSelect( ( select ) => { + const { canEditCSS, revisionsCount } = useSelect( ( select ) => { const { getEntityRecord, __experimentalGetCurrentGlobalStylesId } = select( coreStore ); @@ -58,12 +59,23 @@ function GlobalStylesActionMenu() { return { canEditCSS: !! globalStyles?._links?.[ 'wp:action-edit-css' ] ?? false, + revisionsCount: + globalStyles?._links?.[ 'version-history' ]?.[ 0 ]?.count ?? 0, }; }, [] ); const { useGlobalStylesReset } = unlock( blockEditorPrivateApis ); const [ canReset, onReset ] = useGlobalStylesReset(); const { goTo } = useNavigator(); + const { setEditorCanvasContainerView } = unlock( + useDispatch( editSiteStore ) + ); const loadCustomCSS = () => goTo( '/css' ); + const loadRevisions = () => { + goTo( '/revisions' ); + setEditorCanvasContainerView( 'global-styles-revisions' ); + }; + const hasRevisions = revisionsCount >= 2; + return ( @@ -267,6 +295,10 @@ function GlobalStylesUI() { + + + + { blocks.map( ( block ) => ( ( { + baseConfig: + select( + coreStore + ).__experimentalGetCurrentThemeBaseGlobalStyles(), + } ), + [] + ); + + const mergedConfig = useMemo( () => { + if ( ! isEmpty( userConfig ) && ! isEmpty( baseConfig ) ) { + return mergeBaseAndUserConfigs( baseConfig, userConfig ); + } + return {}; + }, [ baseConfig, userConfig ] ); + + const renderedBlocksArray = useMemo( + () => ( Array.isArray( blocks ) ? blocks : [ blocks ] ), + [ blocks ] + ); + + const originalSettings = useSelect( + ( select ) => select( blockEditorStore ).getSettings(), + [] + ); + const settings = useMemo( + () => ( { ...originalSettings, __unstableIsPreviewMode: true } ), + [ originalSettings ] + ); + + const [ globalStyles ] = useGlobalStylesOutputWithConfig( mergedConfig ); + + const editorStyles = + ! isEmpty( globalStyles ) && ! isEmpty( userConfig ) + ? globalStyles + : settings.styles; + + return ( + + + + ); +} + +export default Revisions; diff --git a/packages/edit-site/src/components/sidebar-edit-mode/sidebar-fixed-bottom.js b/packages/edit-site/src/components/sidebar-edit-mode/sidebar-fixed-bottom.js new file mode 100644 index 00000000000000..c44b8c9c85c7fc --- /dev/null +++ b/packages/edit-site/src/components/sidebar-edit-mode/sidebar-fixed-bottom.js @@ -0,0 +1,26 @@ +/** + * WordPress dependencies + */ +import { privateApis as componentsPrivateApis } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { unlock } from '../../private-apis'; + +const { createPrivateSlotFill } = unlock( componentsPrivateApis ); +const SIDEBAR_FIXED_BOTTOM_SLOT_FILL_NAME = 'SidebarFixedBottom'; +const { Slot: SidebarFixedBottomSlot, Fill: SidebarFixedBottomFill } = + createPrivateSlotFill( SIDEBAR_FIXED_BOTTOM_SLOT_FILL_NAME ); + +export default function SidebarFixedBottom( { children } ) { + return ( + +
+ { children } +
+
+ ); +} + +export { SidebarFixedBottomSlot }; diff --git a/packages/edit-site/src/components/sidebar-edit-mode/style.scss b/packages/edit-site/src/components/sidebar-edit-mode/style.scss index eeb5dc2d170cd2..544c38e0ef07b1 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/style.scss +++ b/packages/edit-site/src/components/sidebar-edit-mode/style.scss @@ -99,3 +99,13 @@ } } } + +.edit-site-sidebar-fixed-bottom-slot { + position: sticky; + bottom: 0; + background: $white; + display: flex; + padding: $grid-unit-20; + border-top: $border-width solid $gray-300; + box-sizing: content-box; +} diff --git a/packages/edit-site/src/style.scss b/packages/edit-site/src/style.scss index 30abf4057b80e8..b24f366b9d7d3a 100644 --- a/packages/edit-site/src/style.scss +++ b/packages/edit-site/src/style.scss @@ -5,6 +5,7 @@ @import "./components/canvas-spinner/style.scss"; @import "./components/code-editor/style.scss"; @import "./components/global-styles/style.scss"; +@import "./components/global-styles/screen-revisions/style.scss"; @import "./components/header-edit-mode/style.scss"; @import "./components/header-edit-mode/document-actions/style.scss"; @import "./components/list/style.scss"; diff --git a/test/e2e/specs/editor/blocks/buttons.spec.js b/test/e2e/specs/editor/blocks/buttons.spec.js index 9d86e206f02193..13d5759ee7db9c 100644 --- a/test/e2e/specs/editor/blocks/buttons.spec.js +++ b/test/e2e/specs/editor/blocks/buttons.spec.js @@ -182,11 +182,11 @@ test.describe( 'Buttons', () => { `role=region[name="Editor settings"i] >> role=tab[name="Styles"i]` ); await page.click( - 'role=region[name="Editor settings"i] >> role=button[name="Text"i]' + 'role=region[name="Editor settings"i] >> role=button[name="Color Text styles"i]' ); await page.click( 'role=button[name="Color: Cyan bluish gray"i]' ); await page.click( - 'role=region[name="Editor settings"i] >> role=button[name="Background"i]' + 'role=region[name="Editor settings"i] >> role=button[name="Color Background styles"i]' ); await page.click( 'role=button[name="Color: Vivid red"i]' ); @@ -211,13 +211,13 @@ test.describe( 'Buttons', () => { `role=region[name="Editor settings"i] >> role=tab[name="Styles"i]` ); await page.click( - 'role=region[name="Editor settings"i] >> role=button[name="Text"i]' + 'role=region[name="Editor settings"i] >> role=button[name="Color Text styles"i]' ); await page.click( 'role=button[name="Custom color picker."i]' ); await page.fill( 'role=textbox[name="Hex color"i]', 'ff0000' ); await page.click( - 'role=region[name="Editor settings"i] >> role=button[name="Background"i]' + 'role=region[name="Editor settings"i] >> role=button[name="Color Background styles"i]' ); await page.click( 'role=button[name="Custom color picker."i]' ); await page.fill( 'role=textbox[name="Hex color"i]', '00ff00' ); @@ -246,7 +246,7 @@ test.describe( 'Buttons', () => { `role=region[name="Editor settings"i] >> role=tab[name="Styles"i]` ); await page.click( - 'role=region[name="Editor settings"i] >> role=button[name="Background"i]' + 'role=region[name="Editor settings"i] >> role=button[name="Color Background styles"i]' ); await page.click( 'role=tab[name="Gradient"i]' ); await page.click( 'role=button[name="Gradient: Purple to yellow"i]' ); @@ -275,7 +275,7 @@ test.describe( 'Buttons', () => { `role=region[name="Editor settings"i] >> role=tab[name="Styles"i]` ); await page.click( - 'role=region[name="Editor settings"i] >> role=button[name="Background"i]' + 'role=region[name="Editor settings"i] >> role=button[name="Color Background styles"i]' ); await page.click( 'role=tab[name="Gradient"i]' ); await page.click( diff --git a/test/e2e/specs/editor/various/keep-styles-on-block-transforms.spec.js b/test/e2e/specs/editor/various/keep-styles-on-block-transforms.spec.js index 2c07a15bcca90e..e4857f84d46c36 100644 --- a/test/e2e/specs/editor/various/keep-styles-on-block-transforms.spec.js +++ b/test/e2e/specs/editor/various/keep-styles-on-block-transforms.spec.js @@ -15,7 +15,7 @@ test.describe( 'Keep styles on block transforms', () => { await page.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '## Heading' ); await editor.openDocumentSettingsSidebar(); - await page.click( 'role=button[name="Text"i]' ); + await page.click( 'role=button[name="Color Text styles"i]' ); await page.click( 'role=button[name="Color: Luminous vivid orange"i]' ); await page.click( 'role=button[name="Heading"i]' ); diff --git a/test/e2e/specs/site-editor/push-to-global-styles.spec.js b/test/e2e/specs/site-editor/push-to-global-styles.spec.js index 57e7e3aea34d99..4f51cbd88aad67 100644 --- a/test/e2e/specs/site-editor/push-to-global-styles.spec.js +++ b/test/e2e/specs/site-editor/push-to-global-styles.spec.js @@ -81,7 +81,9 @@ test.describe( 'Push to Global Styles button', () => { ).toBeDisabled(); // Navigate again to Styles -> Blocks -> Heading -> Typography - await page.getByRole( 'button', { name: 'Styles' } ).click(); + await page + .getByRole( 'button', { name: 'Styles', exact: true } ) + .click(); await page.getByRole( 'button', { name: 'Blocks styles' } ).click(); await page .getByRole( 'button', { name: 'Heading block styles' } ) diff --git a/test/e2e/specs/site-editor/style-variations.spec.js b/test/e2e/specs/site-editor/style-variations.spec.js index 84a5eefb1cda7d..f24f0691b42a49 100644 --- a/test/e2e/specs/site-editor/style-variations.spec.js +++ b/test/e2e/specs/site-editor/style-variations.spec.js @@ -86,13 +86,13 @@ test.describe( 'Global styles variations', () => { await expect( page.locator( - 'role=button[name="Background"i] >> .component-color-indicator' + 'role=button[name="Color Background styles"i] >> .component-color-indicator' ) ).toHaveCSS( 'background', /rgb\(202, 105, 211\)/ ); await expect( page.locator( - 'role=button[name="Text"i] >> .component-color-indicator' + 'role=button[name="Color Text styles"i] >> .component-color-indicator' ) ).toHaveCSS( 'background', /rgb\(74, 7, 74\)/ ); @@ -127,13 +127,13 @@ test.describe( 'Global styles variations', () => { await expect( page.locator( - 'role=button[name="Background"i] >> .component-color-indicator' + 'role=button[name="Color Background styles"i] >> .component-color-indicator' ) ).toHaveCSS( 'background', /rgb\(255, 239, 11\)/ ); await expect( page.locator( - 'role=button[name="Text"i] >> .component-color-indicator' + 'role=button[name="Color Text styles"i] >> .component-color-indicator' ) ).toHaveCSS( 'background', /rgb\(25, 25, 17\)/ ); diff --git a/test/e2e/specs/site-editor/user-global-styles-revisions.spec.js b/test/e2e/specs/site-editor/user-global-styles-revisions.spec.js new file mode 100644 index 00000000000000..5a5880ba7c8b53 --- /dev/null +++ b/test/e2e/specs/site-editor/user-global-styles-revisions.spec.js @@ -0,0 +1,161 @@ +/** + * WordPress dependencies + */ +const { + test, + expect, + Editor, +} = require( '@wordpress/e2e-test-utils-playwright' ); + +test.use( { + editor: async ( { page }, use ) => { + await use( new Editor( { page } ) ); + }, + userGlobalStylesRevisions: async ( { page, requestUtils }, use ) => { + await use( new UserGlobalStylesRevisions( { page, requestUtils } ) ); + }, +} ); + +test.describe( 'Global styles revisions', () => { + test.beforeAll( async ( { requestUtils } ) => { + await Promise.all( [ + requestUtils.activateTheme( 'emptytheme' ), + requestUtils.deleteAllTemplates( 'wp_template' ), + requestUtils.deleteAllTemplates( 'wp_template_part' ), + ] ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); + } ); + + test.beforeEach( async ( { admin } ) => { + await admin.visitSiteEditor( { + canvas: 'edit', + } ); + } ); + + test( 'should display revisions UI when there is more than 1 revision', async ( { + page, + editor, + userGlobalStylesRevisions, + } ) => { + const currentRevisions = + await userGlobalStylesRevisions.getGlobalStylesRevisions(); + + // Navigates to Styles -> Typography -> Text and click on a size. + await page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Styles' } ) + .click(); + + /* + * There are not enough revisions to show the revisions UI yet, so let's create some. + * The condition exists until we have way (and the requirement) to delete global styles revisions. + */ + if ( currentRevisions.length < 1 ) { + // Change a style and save it. + await page + .getByRole( 'button', { name: 'Typography styles' } ) + .click(); + await page + .getByRole( 'button', { name: 'Typography Text styles' } ) + .click(); + await page + .getByRole( 'radiogroup', { name: 'Font size' } ) + .getByRole( 'radio', { name: 'Large', exact: true } ) + .click(); + await editor.saveSiteEditorEntities(); + + // Change a style and save it again just for good luck. + // We need more than 2 revisions to show the UI. + await page + .getByRole( 'radiogroup', { name: 'Font size' } ) + .getByRole( 'radio', { name: 'Medium', exact: true } ) + .click(); + + await editor.saveSiteEditorEntities(); + + // Now there should be enough revisions to show the revisions UI. + await page + .getByRole( 'button', { name: 'Styles actions' } ) + .click(); + await page.getByRole( 'menuitem', { name: 'Revisions' } ).click(); + + const revisionButtons = page.getByRole( 'button', { + name: /^Changes saved by /, + } ); + + await expect( revisionButtons ).toHaveCount( + currentRevisions.length + 2 + ); + } + + const updatedCurrentRevisions = + await userGlobalStylesRevisions.getGlobalStylesRevisions(); + // There are some revisions. Let's check that the UI looks how we expect it to. + await page.getByRole( 'button', { name: 'Styles actions' } ).click(); + await page.getByRole( 'menuitem', { name: 'Revisions' } ).click(); + const revisionButtons = page.getByRole( 'button', { + name: /^Changes saved by /, + } ); + + await expect( revisionButtons ).toHaveCount( + updatedCurrentRevisions.length + ); + } ); + + test( 'should warn of unsaved changes before loading reset revision', async ( { + page, + } ) => { + // Navigates to Styles -> Typography -> Text and click on a size. + await page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Styles' } ) + .click(); + + await page.getByRole( 'button', { name: 'Colors styles' } ).click(); + await page + .getByRole( 'button', { name: 'Color Background styles' } ) + .click(); + await page.getByRole( 'button', { name: 'Color: Black' } ).click(); + await page.getByRole( 'button', { name: 'Styles actions' } ).click(); + await page.getByRole( 'menuitem', { name: 'Revisions' } ).click(); + const unSavedButton = page.getByRole( 'button', { + name: /^Unsaved changes/, + } ); + + await expect( unSavedButton ).toBeVisible(); + + // await expect( image ).toHaveCSS( 'height', '3px' ); + await page + .getByRole( 'button', { name: /^Changes saved by / } ) + .last() + .click(); + + await page.getByRole( 'button', { name: 'Load revision' } ).click(); + + const confirm = page.getByRole( 'dialog' ); + await expect( confirm ).toBeVisible(); + await expect( confirm ).toHaveText( + /^Loading this revision will discard all unsaved changes/ + ); + } ); +} ); + +class UserGlobalStylesRevisions { + constructor( { page, requestUtils } ) { + this.page = page; + this.requestUtils = requestUtils; + } + async getGlobalStylesRevisions() { + const stylesPostId = + await this.requestUtils.getCurrentThemeGlobalStylesPostId(); + if ( stylesPostId ) { + return await this.requestUtils.getThemeGlobalStylesRevisions( + stylesPostId + ); + } + return []; + } +}