From 81cd23380f5908022854bd0d367503bfb52ff9d7 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 8 May 2023 12:28:13 +1000 Subject: [PATCH] Global styles: add revisions UI (#50089) * initial commit. * Adding state for revisions Adding revisions global styles sidebar UI Adding revisions fill Adding revisions components and styles. Added e2e tests * Making selectors more consistent in e2e tests * This commit: - redesigns the revisions list to show a timeline view - changes the revisions endpoint response (and tests) to return a human friendly diff only. The frontend can format dates on the fly - Adds a slot to the edit side sidebar that can position a component at the bottom of the interface sidebar - updates e2e tests * This commit: - adds wordpress/date to package-lock.jdon - ensures that we correctly modify the user revisions to add unsaved and islatest flags * This commit: - extends the e2e test a little bit and adds a TODO to do it better - Adds ally labels to the color panel * This commit: - uses a clientside human-readable time diff method instead of the server side one. This is to make the response as close to the post revisions response as possible (for now) - makes the current timeline dot blue This commit: * This commit: - removes the specific author/user properties from the rest controller - grabs revision author information in the JS - shows a loading wheel while we grab revisions and user data * Label titles * Style adjustments Adding a reset to theme default item to the revisions list. Adding a dependencies to the `useMemo` so that it knows to update when the number of revisions change. This is so we can ensure we add the right metadata in the right order, e.g., `isLatest` Extracted hooks and components from screen revisions monster file * Adding tests for the useGetGlobalStylesRevisions hook * Extracting SCSS to external file Renaming date > meta in classname of DIV that wraps revision date and author meta Extra checks so that we don't render the meta block at all if there's no data * Reverting the reset theme default button and ensuring that revisions panel only display when there are more than two revisions. Updated tests accordingly. bumping gravatar size to 48 Updating other E2E tests after having added aria labels to the color controls Update e2e button spec after changes to button labels * This commit: - refactors useGlobalOutput by splitting out the build logic into a separate hook so that we can pass a custom config - uses wp.date.getSettings().formats.datetimeAbbreviated for formatted date in the revisions buttons - removes unnecessary role on the ordered list - updated copy - replacing modal component with the confirm dialog component - minor code optimizations - making the revisions store methods stable - updating tests - removing shouldShowClose button prop on the editor-canvas-container update e2e * Tweaked the revisions buttons copy, shifting the date and meta around. Updated tests --------- Co-authored-by: James Koster --- docs/reference-guides/data/data-core.md | 12 ++ package-lock.json | 1 + .../components/global-styles/color-panel.js | 7 +- .../src/components/global-styles/index.js | 6 +- .../components/global-styles/test/utils.js | 58 +++++- .../global-styles/use-global-styles-output.js | 24 ++- .../src/components/global-styles/utils.js | 27 +++ packages/core-data/README.md | 12 ++ packages/core-data/src/actions.js | 19 ++ packages/core-data/src/reducer.js | 21 +++ packages/core-data/src/resolvers.js | 47 ++++- packages/core-data/src/selectors.ts | 21 +++ packages/date/README.md | 13 ++ packages/date/src/index.js | 14 ++ packages/date/src/test/index.js | 24 +++ .../src/request-utils/index.ts | 12 +- .../src/request-utils/themes.ts | 52 ++++- packages/edit-site/package.json | 1 + .../editor-canvas-container/index.js | 32 ++-- .../edit-site/src/components/editor/index.js | 6 +- .../global-styles/screen-revisions/index.js | 178 ++++++++++++++++++ .../screen-revisions/revisions-buttons.js | 131 +++++++++++++ .../global-styles/screen-revisions/style.scss | 99 ++++++++++ .../test/use-global-styles-revisions.js | 125 ++++++++++++ .../use-global-styles-revisions.js | 103 ++++++++++ .../style-variations-container.js | 14 +- .../src/components/global-styles/ui.js | 36 +++- .../src/components/revisions/index.js | 104 ++++++++++ .../sidebar-edit-mode/sidebar-fixed-bottom.js | 26 +++ .../components/sidebar-edit-mode/style.scss | 10 + packages/edit-site/src/style.scss | 1 + test/e2e/specs/editor/blocks/buttons.spec.js | 12 +- .../keep-styles-on-block-transforms.spec.js | 2 +- .../site-editor/push-to-global-styles.spec.js | 4 +- .../site-editor/style-variations.spec.js | 8 +- .../user-global-styles-revisions.spec.js | 161 ++++++++++++++++ 36 files changed, 1375 insertions(+), 48 deletions(-) create mode 100644 packages/edit-site/src/components/global-styles/screen-revisions/index.js create mode 100644 packages/edit-site/src/components/global-styles/screen-revisions/revisions-buttons.js create mode 100644 packages/edit-site/src/components/global-styles/screen-revisions/style.scss create mode 100644 packages/edit-site/src/components/global-styles/screen-revisions/test/use-global-styles-revisions.js create mode 100644 packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js create mode 100644 packages/edit-site/src/components/revisions/index.js create mode 100644 packages/edit-site/src/components/sidebar-edit-mode/sidebar-fixed-bottom.js create mode 100644 test/e2e/specs/site-editor/user-global-styles-revisions.spec.js diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index e8daf6e34e978..1ee04e09550e2 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 1553d82a365d5..24e57ea2f08f8 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 b5f73f2340f64..a24a87e7d9a20 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 7e41c11c507e7..2eb6a0f3287e5 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 7d0e3464557f6..0bca8da6f7da9 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 8777869d43621..55dbab06de152 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 d1abd0a57dbc2..0207110ae5223 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 93001a45334bf..dddc3550e03b2 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 bd6839c8b8afb..ffae417a83cd1 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 28ef468a487e7..f04d543919b8c 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 41f95b7d357c1..6437b75997690 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 ebf803c8055ab..7513d91810967 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 7cd3116c73c4c..8edd4e94a8538 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 2417d1b5edf85..9ac47f3a0a5f7 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 36414949af16a..ff82748e02f23 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 e05e59bacc673..99a11bf995679 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 802530d947b0f..6b4fd17588737 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 ba3586301830c..c172941f7fe00 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 c8fcca4a3a99c..091c5b9ec60f6 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 62b62082e4b92..eb05e2526a55f 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 0000000000000..a0c7085b5f49f --- /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 0000000000000..c4d624bf1727e --- /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 0000000000000..2a214663447a8 --- /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 0000000000000..d1e36ec9e595f --- /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 0000000000000..e8f714ba72e9b --- /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 10d93a715405f..074d86af4f80f 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 ebc2ae164aa41..133e8d61d1148 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 0000000000000..c44b8c9c85c7f --- /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 eeb5dc2d170cd..544c38e0ef07b 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 30abf4057b80e..b24f366b9d7d3 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 9d86e206f0219..13d5759ee7db9 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 2c07a15bcca90..e4857f84d46c3 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 57e7e3aea34d9..4f51cbd88aad6 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 84a5eefb1cda7..f24f0691b42a4 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 0000000000000..5a5880ba7c8b5 --- /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 []; + } +}