From 15c9e21bf551e4a0e815b026d23555145cc6cc01 Mon Sep 17 00:00:00 2001 From: Bruno Ribaric Date: Sun, 8 Jan 2023 21:15:35 +0100 Subject: [PATCH 01/13] WIP: Saving custom variations --- ...class-wp-theme-json-resolver-gutenberg.php | 146 ++++++++++- ...berg-rest-global-styles-controller-6-2.php | 244 +++++++++++++++++- ...s-gutenberg-rest-themes-controller-6-2.php | 51 ++++ lib/compat/wordpress-6.2/rest-api.php | 6 + lib/load.php | 1 + packages/core-data/src/actions.js | 64 +++++ packages/core-data/src/reducer.js | 31 +++ packages/core-data/src/resolvers.js | 31 ++- packages/core-data/src/selectors.ts | 29 ++- .../global-styles/global-styles-provider.js | 141 ++++++++-- .../src/components/global-styles/header.js | 3 +- .../src/components/global-styles/hooks.js | 35 +++ .../components/global-styles/screen-root.js | 4 +- .../global-styles/screen-style-variations.js | 172 +++++++++++- .../src/components/global-styles/style.scss | 18 ++ 15 files changed, 918 insertions(+), 58 deletions(-) create mode 100644 lib/compat/wordpress-6.2/class-gutenberg-rest-themes-controller-6-2.php diff --git a/lib/class-wp-theme-json-resolver-gutenberg.php b/lib/class-wp-theme-json-resolver-gutenberg.php index 8733fd13bf2c7..d8b67faba239e 100644 --- a/lib/class-wp-theme-json-resolver-gutenberg.php +++ b/lib/class-wp-theme-json-resolver-gutenberg.php @@ -432,7 +432,7 @@ public static function get_user_data_from_wp_global_styles( $theme, $create_post $args = array( 'posts_per_page' => 1, 'orderby' => 'date', - 'order' => 'desc', + 'order' => 'asc', 'post_type' => $post_type_filter, 'post_status' => $post_status_filter, 'ignore_sticky_posts' => true, @@ -453,19 +453,14 @@ public static function get_user_data_from_wp_global_styles( $theme, $create_post if ( count( $recent_posts ) === 1 ) { $user_cpt = get_object_vars( $recent_posts[0] ); } elseif ( $create_post ) { - $cpt_post_id = wp_insert_post( + $cpt_post_id = self::add_user_global_styles_variation( array( - 'post_content' => '{"version": ' . WP_Theme_JSON_Gutenberg::LATEST_SCHEMA . ', "isGlobalStylesUserThemeJSON": true }', - 'post_status' => 'publish', - 'post_title' => 'Custom Styles', // Do not make string translatable, see https://core.trac.wordpress.org/ticket/54518. - 'post_type' => $post_type_filter, - 'post_name' => sprintf( 'wp-global-styles-%s', urlencode( $stylesheet ) ), - 'tax_input' => array( - 'wp_theme' => array( $stylesheet ), - ), - ), - true + 'title' => 'Custom Styles', // Do not make string translatable, see https://core.trac.wordpress.org/ticket/54518. + 'global_styles' => '{"version": ' . WP_Theme_JSON_Gutenberg::LATEST_SCHEMA . ', "isGlobalStylesUserThemeJSON": true }', + 'stylesheet' => $stylesheet, + ) ); + if ( ! is_wp_error( $cpt_post_id ) ) { $user_cpt = get_object_vars( get_post( $cpt_post_id ) ); } @@ -474,6 +469,87 @@ public static function get_user_data_from_wp_global_styles( $theme, $create_post return $user_cpt; } + /** + * Saves a new user variation into the database. + * + * @param array $args Arguments. All are required. + * { + * @type string title Global styles variation name. + * @type string global_styles Global styles settings as a JSON string. + * @type string $stylesheet Slug of the theme associated with these global styles. + * } + * + * @return int|WP_Error Post ID of the new variation or error if insertion failed. + */ + public static function add_user_global_styles_variation( $args ) { + $theme = wp_get_theme(); + + /* + * Bail early if the theme does not support a theme.json. + * + * Since wp_theme_has_theme_json only supports the active + * theme, the extra condition for whether $theme is the active theme is + * present here. + */ + if ( $theme->get_stylesheet() === $args['stylesheet'] && ! wp_theme_has_theme_json() ) { + return new WP_Error( __( 'Theme does not have theme.json', 'gutenberg' ) ); + } + + $post_id = wp_insert_post( + array( + 'post_content' => $args['global_styles'], + 'post_status' => 'publish', + 'post_title' => $args['title'], + 'post_type' => 'wp_global_styles', + 'post_name' => sprintf( 'wp-global-styles-%s', urlencode( $args['stylesheet'] ) ), + 'tax_input' => array( + 'wp_theme' => array( $args['stylesheet'] ), + ), + ), + true + ); + + return $post_id; + } + + /** + * Make an association between post $id and post containing current user + * global styles + * + * @since 6.2 + * + * @param int|null $id ID of the associated post. Null to delete the asociation. + * @return int|false Meta ID on success, false on failure. + */ + public static function associate_user_variation_with_global_styles_post( $id ) { + $current_gs_id = static::get_user_global_styles_post_id(); + + if ( $id === $current_gs_id ) { + return false; + } + + $prev_id = get_post_meta( $current_gs_id, 'associated_user_variation', true ); + + if ( empty( $prev_id ) ) { + return add_post_meta( $current_gs_id, 'associated_user_variation', $id, true ); + } + + return update_post_meta( $current_gs_id, 'associated_user_variation', $id ); + } + + /** + * Get associated variation ID. + * + * @since 6.2 + * + * @return int|null|false Meta ID or null on success, false on failure. + */ + public static function get_associated_user_variation_id() { + $current_gs_id = static::get_user_global_styles_post_id(); + + return get_post_meta( $current_gs_id, 'associated_user_variation', true ); + } + /** * Returns the user's origin config. * @@ -698,4 +774,50 @@ public static function get_style_variations() { return $variations; } + /** + * Returns the style variations defined by the user. + * + * @since 6.2.0 + * + * @return array + */ + public static function get_user_style_variations() { + $stylesheet = get_stylesheet(); + + $initial_variation = (int) static::get_user_global_styles_post_id(); + + $args = array( + 'posts_per_page' => -1, + 'orderby' => 'date', + 'order' => 'desc', + 'post_type' => 'wp_global_styles', + 'post_status' => 'publish', + 'ignore_sticky_posts' => true, + 'no_found_rows' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + 'post__not_in' => array( $initial_variation ), + 'tax_query' => array( + array( + 'taxonomy' => 'wp_theme', + 'field' => 'name', + 'terms' => $stylesheet, + ), + ), + ); + + $global_style_query = new WP_Query(); + $variation_posts = $global_style_query->query( $args ); + $variations = array(); + + foreach ( $variation_posts as $variation_post ) { + $decoded = json_decode( $variation_post->post_content, true ); + $variation = ( new WP_Theme_JSON_Gutenberg( $decoded ) )->get_raw_data(); + $variation['title'] = $variation_post->post_title; + $variation['id'] = $variation_post->ID; + $variations[] = $variation; + } + + return $variations; + } } diff --git a/lib/compat/wordpress-6.2/class-gutenberg-rest-global-styles-controller-6-2.php b/lib/compat/wordpress-6.2/class-gutenberg-rest-global-styles-controller-6-2.php index 643374aba490c..75241982d6c63 100644 --- a/lib/compat/wordpress-6.2/class-gutenberg-rest-global-styles-controller-6-2.php +++ b/lib/compat/wordpress-6.2/class-gutenberg-rest-global-styles-controller-6-2.php @@ -16,9 +16,147 @@ class Gutenberg_REST_Global_Styles_Controller_6_2 extends WP_REST_Global_Styles_ * @return void */ public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this, 'create_item_permissions_check' ), + 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/user/variations', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_user_items' ), + 'permission_callback' => array( $this, 'get_user_items_permissions_check' ), + ), + ) + ); + parent::register_routes(); } + /** + * Returns the user global styles variations. + * + * @since 6.2 + * + * @param WP_REST_Request $request The request instance. + * + * @return WP_REST_Response|WP_Error + */ + public function get_user_items( $request ) { + $variations = WP_Theme_JSON_Resolver_Gutenberg::get_user_style_variations(); + $response = rest_ensure_response( $variations ); + return $response; + } + + /** + * Checks if a given request has access to read a single user global styles config. + * + * @since 6.2 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise. + */ + public function get_user_items_permissions_check( $request ) { + // Verify if the current user has edit_theme_options capability. + // This capability is required to edit/view/delete templates. + if ( ! current_user_can( 'edit_theme_options' ) ) { + return new WP_Error( + 'rest_cannot_manage_global_styles', + __( 'Sorry, you are not allowed to access the global styles on this site.' ), + array( + 'status' => rest_authorization_required_code(), + ) + ); + } + + return true; + } + + /** + * Checks if a given request has access to write a single global styles config. + * + * @since 6.2 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has write access for the item, WP_Error object otherwise. + */ + public function create_item_permissions_check( $request ) { + if ( ! empty( $request['id'] ) ) { + return new WP_Error( + 'rest_post_exists', + __( 'Cannot create existing style variation.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + $post_type = get_post_type_object( $this->post_type ); + + if ( ! current_user_can( $post_type->cap->create_posts ) ) { + return new WP_Error( + 'rest_cannot_create', + __( 'Sorry, you are not allowed to create global style variations as this user.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Adds a single global style config. + * + * @since 6.2 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function create_item( $request ) { + $changes = $this->prepare_item_for_database( $request ); + + if ( is_wp_error( $changes ) ) { + return $changes; + } + + $stylesheet = get_stylesheet(); + + $post_id = WP_Theme_JSON_Resolver_Gutenberg::add_user_global_styles_variation( + array( + 'title' => sanitize_text_field( $request['title'] ), + 'global_styles' => $changes->post_content, + 'stylesheet' => $stylesheet, + ) + ); + + if ( is_wp_error( $post_id ) ) { + + if ( 'db_insert_error' === $post_id->get_error_code() ) { + $post_id->add_data( array( 'status' => 500 ) ); + } else { + $post_id->add_data( array( 'status' => 400 ) ); + } + + return $post_id; + } + + $post = get_post( $post_id ); + + $response = $this->prepare_item_for_response( $post, $request ); + + return rest_ensure_response( $response ); + } + /** * Prepare a global styles config output for response. * @@ -67,6 +205,16 @@ public function prepare_item_for_response( $post, $request ) { // phpcs:ignore V $data['styles'] = ! empty( $config['styles'] ) && $is_global_styles_user_theme_json ? $config['styles'] : new stdClass(); } + if ( rest_is_field_included( 'associated_style_id', $fields ) ) { + $associated_style_id = get_post_meta( $post->ID, 'associated_user_variation', true ); + + if ( ! empty( $associated_style_id ) ) { + $data['associated_style_id'] = (int) $associated_style_id; + } else { + $data['associated_style_id'] = null; + } + } + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); @@ -104,6 +252,24 @@ public function update_item( $request ) { return $post_before; } + if ( ! empty( $request['associated_style_id'] ) ) { + if ( $request['id'] !== WP_Theme_JSON_Resolver_Gutenberg::get_user_global_styles_post_id() ) { + return new WP_Error( + 'rest_cannot_edit', + __( 'Sorry, you are not allowed to add an associated style id to this global style.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + if ( is_numeric( $request['associated_style_id'] ) && $request['associated_style_id'] < 0 ) { + $id = null; + } else { + $id = $request['associated_style_id']; + } + + WP_Theme_JSON_Resolver_Gutenberg::associate_user_variation_with_global_styles_post( $id ); + } + $changes = $this->prepare_item_for_database( $request ); if ( is_wp_error( $changes ) ) { return $changes; @@ -136,10 +302,16 @@ public function update_item( $request ) { * @return stdClass Changes to pass to wp_update_post. */ protected function prepare_item_for_database( $request ) { - $changes = new stdClass(); - $changes->ID = $request['id']; - $post = get_post( $request['id'] ); + $changes = new stdClass(); + if ( ! empty( $request['id'] ) ) { + $changes->ID = $request['id']; + $post = get_post( $request['id'] ); + } else { + $post = null; + } + $existing_config = array(); + if ( $post ) { $existing_config = json_decode( $post->post_content, true ); $json_decoding_error = json_last_error(); @@ -252,4 +424,70 @@ public function get_theme_item( $request ) { return $response; } + + /** + * Retrieves the global styles type' schema, conforming to JSON Schema. + * + * @since 5.9.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => $this->post_type, + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'ID of global styles config.', 'gutenberg' ), + 'type' => 'string', + 'context' => array( 'embed', 'view', 'edit' ), + 'readonly' => true, + ), + 'styles' => array( + 'description' => __( 'Global styles.', 'gutenberg' ), + 'type' => array( 'object' ), + 'context' => array( 'view', 'edit' ), + ), + 'settings' => array( + 'description' => __( 'Global settings.', 'gutenberg' ), + 'type' => array( 'object' ), + 'context' => array( 'view', 'edit' ), + ), + 'title' => array( + 'description' => __( 'Title of the global styles variation.', 'gutenberg' ), + 'type' => array( 'object', 'string' ), + 'default' => '', + 'context' => array( 'embed', 'view', 'edit' ), + 'properties' => array( + 'raw' => array( + 'description' => __( 'Title for the global styles variation, as it exists in the database.', 'gutenberg' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'rendered' => array( + 'description' => __( 'HTML title for the post, transformed for display.', 'gutenberg' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + ), + ), + 'associated_style_id' => array( + 'description' => __( 'ID of the associated variation style.', 'gutenberg' ), + 'type' => array( 'null', 'integer' ), + 'context' => array( 'view', 'edit' ), + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } + } diff --git a/lib/compat/wordpress-6.2/class-gutenberg-rest-themes-controller-6-2.php b/lib/compat/wordpress-6.2/class-gutenberg-rest-themes-controller-6-2.php new file mode 100644 index 0000000000000..0eb91ee88ff74 --- /dev/null +++ b/lib/compat/wordpress-6.2/class-gutenberg-rest-themes-controller-6-2.php @@ -0,0 +1,51 @@ + array( + 'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $theme->get_stylesheet() ) ), + ), + 'collection' => array( + 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), + ), + ); + + if ( $this->is_same_theme( $theme, wp_get_theme() ) ) { + // This creates a record for the active theme if not existent. + $id = WP_Theme_JSON_Resolver_Gutenberg::get_user_global_styles_post_id(); + } else { + $user_cpt = WP_Theme_JSON_Resolver_Gutenberg::get_user_data_from_wp_global_styles( $theme ); + $id = isset( $user_cpt['ID'] ) ? $user_cpt['ID'] : null; + } + + if ( $id ) { + $links['https://api.w.org/user-global-styles'] = array( + 'href' => rest_url( 'wp/v2/global-styles/' . $id ), + ); + } + + return $links; + } +} diff --git a/lib/compat/wordpress-6.2/rest-api.php b/lib/compat/wordpress-6.2/rest-api.php index 12f7afda3b4d5..b5478e9a21c12 100644 --- a/lib/compat/wordpress-6.2/rest-api.php +++ b/lib/compat/wordpress-6.2/rest-api.php @@ -102,6 +102,12 @@ function gutenberg_register_global_styles_endpoints() { } add_action( 'rest_api_init', 'gutenberg_register_global_styles_endpoints' ); +function gutenberg_register_themes_endpoints() { + $controller = new Gutenberg_REST_Themes_Controller_6_2(); + $controller->register_routes(); +} +add_action( 'rest_api_init', 'gutenberg_register_themes_endpoints' ); + /** * Updates REST API response for the sidebars and marks them as 'inactive'. * diff --git a/lib/load.php b/lib/load.php index 1b29605396531..71584336f439a 100644 --- a/lib/load.php +++ b/lib/load.php @@ -44,6 +44,7 @@ function gutenberg_is_experiment_enabled( $name ) { require_once __DIR__ . '/compat/wordpress-6.2/class-gutenberg-rest-block-patterns-controller-6-2.php'; require_once __DIR__ . '/compat/wordpress-6.2/class-gutenberg-rest-block-pattern-categories-controller.php'; require_once __DIR__ . '/compat/wordpress-6.2/class-gutenberg-rest-pattern-directory-controller-6-2.php'; + require_once __DIR__ . '/compat/wordpress-6.2/class-gutenberg-rest-themes-controller-6-2.php'; require_once __DIR__ . '/compat/wordpress-6.2/rest-api.php'; require_once __DIR__ . '/compat/wordpress-6.2/block-patterns.php'; require_once __DIR__ . '/compat/wordpress-6.2/class-gutenberg-rest-global-styles-controller-6-2.php'; diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 307e5cef0e229..f905cb1cc67c7 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -193,6 +193,28 @@ export function __experimentalReceiveThemeGlobalStyleVariations( }; } +/** + * Returns an action object used in signalling that the user global styles variations have been received. + * Ignored from documentation as it's internal to the data store. + * + * @ignore + * + * @param {string} stylesheet Stylesheet. + * @param {Array} variations The global styles variations. + * + * @return {Object} Action object. + */ +export function __experimentalReceiveUserGlobalStyleVariations( + stylesheet, + variations +) { + return { + type: 'RECEIVE_USER_GLOBAL_STYLE_VARIATIONS', + stylesheet, + variations, + }; +} + /** * Returns an action object used in signalling that the index has been received. * @@ -834,3 +856,45 @@ export function receiveAutosaves( postId, autosaves ) { autosaves: Array.isArray( autosaves ) ? autosaves : [ autosaves ], }; } + +/** + * @param {Object} globalStylesData Global styles configuration. + */ +export const __experimentalCreateNewGlobalStylesVariation = + ( globalStylesData ) => + async ( { dispatch, resolveSelect } ) => { + const currentTheme = await resolveSelect.getCurrentTheme(); + + await apiFetch( { + path: '/wp/v2/global-styles', + method: 'POST', + data: globalStylesData, + } ); + + // Refresh variations + const variations = await apiFetch( { + path: `/wp/v2/global-styles/user/variations`, + } ); + dispatch.__experimentalReceiveUserGlobalStyleVariations( + currentTheme.stylesheet, + variations + ); + }; + +/** + * Tracks whether changes have been made to global styles associated variation + * ID. + * Ignored from documentation as it's internal to the data store. + * + * @ignore + * + * @param {boolean} hasChanged Whether it changed. + * + * @return {Object} Action object. + */ +export function __experimentalAssociatedVariationChanged( hasChanged ) { + return { + type: 'SET_ASSOCIATED_VARIATION_CHANGED', + hasChanged, + }; +} diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 21ecaff436c72..65789251ef7f4 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -187,6 +187,26 @@ export function themeGlobalStyleVariations( state = {}, action ) { return state; } +/** + * Reducer managing the user global styles variations. + * + * @param {Record} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Record} Updated state. + */ +export function userGlobalStyleVariations( state = {}, action ) { + switch ( action.type ) { + case 'RECEIVE_USER_GLOBAL_STYLE_VARIATIONS': + return { + ...state, + [ action.stylesheet ]: action.variations, + }; + } + + return state; +} + /** * Higher Order Reducer for a given entity config. It supports: * @@ -642,6 +662,15 @@ export function blockPatternCategories( state = [], action ) { return state; } +export function associatedVariationChanged( state = false, action ) { + switch ( action.type ) { + case 'SET_ASSOCIATED_VARIATION_CHANGED': + return action.hasChanged; + } + + return state; +} + export default combineReducers( { terms, users, @@ -658,4 +687,6 @@ export default combineReducers( { autosaves, blockPatterns, blockPatternCategories, + userGlobalStyleVariations, + associatedVariationChanged, } ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index b33bb42e65337..852c9d13b78a4 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -487,17 +487,30 @@ export const __experimentalGetCurrentThemeBaseGlobalStyles = ); }; -export const __experimentalGetCurrentThemeGlobalStylesVariations = - () => +/** + * @param {string} [author] Variations author. Either 'theme' or 'user'. + */ +export const __experimentalGetGlobalStylesVariations = + ( author = 'theme' ) => async ( { resolveSelect, dispatch } ) => { const currentTheme = await resolveSelect.getCurrentTheme(); - const variations = await apiFetch( { - path: `/wp/v2/global-styles/themes/${ currentTheme.stylesheet }/variations`, - } ); - dispatch.__experimentalReceiveThemeGlobalStyleVariations( - currentTheme.stylesheet, - variations - ); + if ( author === 'theme' ) { + const variations = await apiFetch( { + path: `/wp/v2/global-styles/themes/${ currentTheme.stylesheet }/variations`, + } ); + dispatch.__experimentalReceiveThemeGlobalStyleVariations( + currentTheme.stylesheet, + variations + ); + } else { + const variations = await apiFetch( { + path: `/wp/v2/global-styles/user/variations`, + } ); + dispatch.__experimentalReceiveUserGlobalStyleVariations( + currentTheme.stylesheet, + variations + ); + } }; export const getBlockPatterns = diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 9998d67728e74..a46d0d777cb64 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -34,9 +34,11 @@ export interface State { embedPreviews: Record< string, { html: string } >; entities: EntitiesState; themeBaseGlobalStyles: Record< string, Object >; - themeGlobalStyleVariations: Record< string, string >; + themeGlobalStyleVariations: Record< string, Object[] >; + userGlobalStyleVariations: Record< string, Object[] >; undo: UndoState; users: UserState; + associatedVariationChanged: boolean; } type EntityRecordKey = string | number; @@ -1238,20 +1240,27 @@ export function __experimentalGetCurrentThemeBaseGlobalStyles( } /** - * Return the ID of the current global styles object. + * Return global styles variations. * * @param state Data state. + * @param author Variations author. Either 'theme' or 'user'. * - * @return The current global styles ID. + * @return Global styles variations */ -export function __experimentalGetCurrentThemeGlobalStylesVariations( - state: State -): string | null { +export function __experimentalGetGlobalStylesVariations( + state: State, + author: 'theme' | 'user' = 'theme' +): Object[] | null { const currentTheme = getCurrentTheme( state ); if ( ! currentTheme ) { return null; } - return state.themeGlobalStyleVariations[ currentTheme.stylesheet ]; + + if ( author === 'theme' ) { + return state.themeGlobalStyleVariations[ currentTheme.stylesheet ]; + } + + return state.userGlobalStyleVariations[ currentTheme.stylesheet ]; } /** @@ -1275,3 +1284,9 @@ export function getBlockPatterns( state: State ): Array< any > { export function getBlockPatternCategories( state: State ): Array< any > { return state.blockPatternCategories; } + +export function __experimentalHasAssociatedVariationChanged( + state: State +): boolean { + return state.associatedVariationChanged; +} diff --git a/packages/edit-site/src/components/global-styles/global-styles-provider.js b/packages/edit-site/src/components/global-styles/global-styles-provider.js index 0813dc63b2cb8..94575be45a855 100644 --- a/packages/edit-site/src/components/global-styles/global-styles-provider.js +++ b/packages/edit-site/src/components/global-styles/global-styles-provider.js @@ -15,6 +15,8 @@ import { store as coreStore } from '@wordpress/core-data'; */ import { GlobalStylesContext } from './context'; +/* eslint-disable dot-notation, camelcase */ + function mergeTreesCustomizer( _, srcValue ) { // We only pass as arrays the presets, // in which case we want the new array of values @@ -45,10 +47,13 @@ const cleanEmptyObject = ( object ) => { }; function useGlobalStylesUserConfig() { - const { globalStylesId, isReady, settings, styles } = useSelect( - ( select ) => { - const { getEditedEntityRecord, hasFinishedResolution } = - select( coreStore ); + const { hasFinishedResolution } = useSelect( coreStore ); + const { editEntityRecord, __experimentalAssociatedVariationChanged } = + useDispatch( coreStore ); + + const { globalStylesId, isReady, settings, styles, associated_style_id } = + useSelect( ( select ) => { + const { getEditedEntityRecord } = select( coreStore ); const _globalStylesId = select( coreStore ).__experimentalGetCurrentGlobalStylesId(); const record = _globalStylesId @@ -58,6 +63,19 @@ function useGlobalStylesUserConfig() { _globalStylesId ) : undefined; + const _associatedStyleId = record + ? record[ 'associated_style_id' ] + : undefined; + if ( + _associatedStyleId && + ! __experimentalAssociatedVariationChanged() + ) { + getEditedEntityRecord( + 'root', + 'globalStyles', + _associatedStyleId + ); + } let hasResolved = false; if ( @@ -65,13 +83,33 @@ function useGlobalStylesUserConfig() { '__experimentalGetCurrentGlobalStylesId' ) ) { - hasResolved = _globalStylesId - ? hasFinishedResolution( 'getEditedEntityRecord', [ + hasResolved = ( () => { + if ( ! _globalStylesId ) { + return false; + } + + const userStyleFinishedResolution = hasFinishedResolution( + 'getEditedEntityRecord', + [ 'root', 'globalStyles', _globalStylesId ] + ); + + if ( ! _associatedStyleId ) { + return userStyleFinishedResolution; + } + + const associatedStyleFinishedResolution = + __experimentalAssociatedVariationChanged() || + hasFinishedResolution( 'getEditedEntityRecord', [ 'root', 'globalStyles', - _globalStylesId, - ] ) - : true; + _associatedStyleId, + ] ); + + return ( + userStyleFinishedResolution && + associatedStyleFinishedResolution + ); + } )(); } return { @@ -79,19 +117,18 @@ function useGlobalStylesUserConfig() { isReady: hasResolved, settings: record?.settings, styles: record?.styles, + associated_style_id: _associatedStyleId, }; - }, - [] - ); + }, [] ); const { getEditedEntityRecord } = useSelect( coreStore ); - const { editEntityRecord } = useDispatch( coreStore ); const config = useMemo( () => { return { settings: settings ?? {}, styles: styles ?? {}, + associated_style_id: associated_style_id ?? null, }; - }, [ settings, styles ] ); + }, [ settings, styles, associated_style_id ] ); const setConfig = useCallback( ( callback, options = {} ) => { @@ -103,18 +140,84 @@ function useGlobalStylesUserConfig() { const currentConfig = { styles: record?.styles ?? {}, settings: record?.settings ?? {}, + associated_style_id: record?.associated_style_id ?? null, }; const updatedConfig = callback( currentConfig ); + const updatedRecord = { + styles: cleanEmptyObject( updatedConfig.styles ) || {}, + settings: cleanEmptyObject( updatedConfig.settings ) || {}, + associated_style_id: + updatedConfig[ 'associated_style_id' ] || null, + }; + + let associatedStyleIdChanged = false; + + if ( + currentConfig[ 'associated_style_id' ] !== + updatedRecord[ 'associated_style_id' ] + ) { + associatedStyleIdChanged = true; + } + + __experimentalAssociatedVariationChanged( + associatedStyleIdChanged + ); + editEntityRecord( 'root', 'globalStyles', globalStylesId, - { - styles: cleanEmptyObject( updatedConfig.styles ) || {}, - settings: cleanEmptyObject( updatedConfig.settings ) || {}, - }, + updatedRecord, options ); + + if ( + ! associatedStyleIdChanged && + updatedRecord[ 'associated_style_id' ] + ) { + if ( + ( ! hasFinishedResolution( 'getEditedEntityRecord' ), + [ + 'root', + 'globalStyles', + updatedRecord[ 'associated_style_id' ], + ] ) + ) { + const intervalId = setInterval( () => { + if ( + ( hasFinishedResolution( 'getEditedEntityRecord' ), + [ + 'root', + 'globalStyles', + updatedRecord[ 'associated_style_id' ], + ] ) + ) { + editEntityRecord( + 'root', + 'globalStyles', + updatedRecord[ 'associated_style_id' ], + { + settings: updatedRecord.settings, + styles: updatedRecord.styles, + }, + options + ); + clearInterval( intervalId ); + } + }, 500 ); + } else { + editEntityRecord( + 'root', + 'globalStyles', + updatedRecord[ 'associated_style_id' ], + { + settings: updatedRecord.settings, + styles: updatedRecord.styles, + }, + options + ); + } + } }, [ globalStylesId ] ); @@ -162,6 +265,8 @@ function useGlobalStylesContext() { return context; } +/* eslint-enable dot-notation, camelcase */ + export function GlobalStylesProvider( { children } ) { const context = useGlobalStylesContext(); if ( ! context.isReady ) { diff --git a/packages/edit-site/src/components/global-styles/header.js b/packages/edit-site/src/components/global-styles/header.js index 8167925098a68..77a253ee98c72 100644 --- a/packages/edit-site/src/components/global-styles/header.js +++ b/packages/edit-site/src/components/global-styles/header.js @@ -12,7 +12,7 @@ import { import { isRTL, __ } from '@wordpress/i18n'; import { chevronRight, chevronLeft } from '@wordpress/icons'; -function ScreenHeader( { title, description } ) { +function ScreenHeader( { title, description, onBackButtonClick } ) { return ( @@ -27,6 +27,7 @@ function ScreenHeader( { title, description } ) { icon={ isRTL() ? chevronRight : chevronLeft } isSmall aria-label={ __( 'Navigate to the previous view' ) } + onClick={ onBackButtonClick } /> { title } diff --git a/packages/edit-site/src/components/global-styles/hooks.js b/packages/edit-site/src/components/global-styles/hooks.js index c767f1a488cbf..1ea3006f10a81 100644 --- a/packages/edit-site/src/components/global-styles/hooks.js +++ b/packages/edit-site/src/components/global-styles/hooks.js @@ -16,6 +16,8 @@ import { __EXPERIMENTAL_PATHS_WITH_MERGE as PATHS_WITH_MERGE, __EXPERIMENTAL_STYLE_PROPERTY as STYLE_PROPERTY, } from '@wordpress/blocks'; +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect, useDispatch } from '@wordpress/data'; /** * Internal dependencies @@ -364,3 +366,36 @@ export function useColorRandomizer( name ) { ? [ randomizeColors ] : []; } + +export function useHasUserModifiedStyles() { + const { user } = useContext( GlobalStylesContext ); + return ( + Boolean( user ) && + ( Object.keys( user.settings ).length !== 0 || + Object.keys( user.styles ).length !== 0 ) + ); +} + +export function useCreateNewStyleRecord( title ) { + const { __experimentalCreateNewGlobalStylesVariation } = + useDispatch( coreStore ); + const { user } = useContext( GlobalStylesContext ); + const callback = useCallback( () => { + return __experimentalCreateNewGlobalStylesVariation( { + ...user, + title, + } ); + }, [ title, user ] ); + return callback; +} + +export function useCustomSavedStyles() { + const { variations } = useSelect( ( select ) => { + const { __experimentalGetGlobalStylesVariations } = select( coreStore ); + return { + variations: __experimentalGetGlobalStylesVariations( 'user' ), + }; + }, [] ); + + return variations; +} diff --git a/packages/edit-site/src/components/global-styles/screen-root.js b/packages/edit-site/src/components/global-styles/screen-root.js index 0d7959f59fe95..488e0fbd02475 100644 --- a/packages/edit-site/src/components/global-styles/screen-root.js +++ b/packages/edit-site/src/components/global-styles/screen-root.js @@ -29,9 +29,7 @@ function ScreenRoot() { const { variations } = useSelect( ( select ) => { return { variations: - select( - coreStore - ).__experimentalGetCurrentThemeGlobalStylesVariations(), + select( coreStore ).__experimentalGetGlobalStylesVariations(), }; }, [] ); diff --git a/packages/edit-site/src/components/global-styles/screen-style-variations.js b/packages/edit-site/src/components/global-styles/screen-style-variations.js index b88b81a0c08d1..0552a0629d842 100644 --- a/packages/edit-site/src/components/global-styles/screen-style-variations.js +++ b/packages/edit-site/src/components/global-styles/screen-style-variations.js @@ -11,6 +11,7 @@ import { store as coreStore } from '@wordpress/core-data'; import { useSelect, useDispatch } from '@wordpress/data'; import { useMemo, + useCallback, useContext, useState, useEffect, @@ -18,10 +19,18 @@ import { } from '@wordpress/element'; import { ENTER } from '@wordpress/keycodes'; import { - __experimentalGrid as Grid, Card, CardBody, + CardDivider, + Button, + Modal, + __experimentalHeading as Heading, + __experimentalText as Text, + __experimentalHStack as HStack, + __experimentalGrid as Grid, + __experimentalInputControl as InputControl, } from '@wordpress/components'; +import { plus } from '@wordpress/icons'; import { __ } from '@wordpress/i18n'; import { store as blockEditorStore } from '@wordpress/block-editor'; @@ -32,6 +41,13 @@ import { mergeBaseAndUserConfigs } from './global-styles-provider'; import { GlobalStylesContext } from './context'; import StylesPreview from './preview'; import ScreenHeader from './header'; +import { + useHasUserModifiedStyles, + useCreateNewStyleRecord, + useCustomSavedStyles, +} from './hooks'; + +/* eslint-disable dot-notation */ function compareVariations( a, b ) { return ( @@ -105,13 +121,71 @@ function Variation( { variation } ) { ); } +function UserVariation( { variation } ) { + const [ isFocused, setIsFocused ] = useState( false ); + const { user, setUserConfig } = useContext( GlobalStylesContext ); + const associatedStyleId = user[ 'associated_style_id' ]; + + const isActive = useMemo( + () => variation.id === associatedStyleId, + [ variation, associatedStyleId ] + ); + + const selectVariation = useCallback( () => { + setUserConfig( () => ( { + settings: variation.settings, + styles: variation.styles, + associated_style_id: variation.id, + } ) ); + }, [ variation ] ); + + const selectOnEnter = ( event ) => { + if ( event.keyCode === ENTER ) { + event.preventDefault(); + selectVariation(); + } + }; + + return ( +
setIsFocused( true ) } + onBlur={ () => setIsFocused( false ) } + > +
+ +
+
+ ); +} + function ScreenStyleVariations() { + const [ createNewVariationModalOpen, setCreateNewVariationModalOpen ] = + useState( false ); + const [ newStyleName, setNewStyleName ] = useState( '' ); + const [ isStyleRecordSaving, setIsStyleRecordSaving ] = useState( false ); + const { user } = useContext( GlobalStylesContext ); + + const { + __experimentalHasAssociatedVariationChanged, + getEditedEntityRecord, + } = useSelect( coreStore ); const { variations, mode } = useSelect( ( select ) => { return { variations: - select( - coreStore - ).__experimentalGetCurrentThemeGlobalStylesVariations(), + select( coreStore ).__experimentalGetGlobalStylesVariations(), mode: select( blockEditorStore ).__unstableGetEditorMode(), }; @@ -132,6 +206,9 @@ function ScreenStyleVariations() { ]; }, [ variations ] ); + const hasUserModifiedStyles = useHasUserModifiedStyles(); + const userVariations = useCustomSavedStyles(); + const { __unstableSetEditorMode } = useDispatch( blockEditorStore ); const shouldRevertInitialMode = useRef( null ); useEffect( () => { @@ -157,6 +234,24 @@ function ScreenStyleVariations() { } }, [] ); + const createNewStyleRecord = useCreateNewStyleRecord( newStyleName ); + + // TODO Wrap in useCallback + // Needs to be done on back button click, otherwise if getEditedEntityRecord is called + // when a variation is clicked, there is an ugly screen re-render that resets navigation. + const handleBackButtonClick = () => { + if ( + user[ 'associated_style_id' ] && + __experimentalHasAssociatedVariationChanged() + ) { + getEditedEntityRecord( + 'root', + 'globalStyles', + user[ 'associated_style_id' ] + ); + } + }; + return ( <> + + + + { __( 'Custom styles' ) } + + + + ) } ); } +/* eslint-enable dot-notation */ + export default ScreenStyleVariations; diff --git a/packages/edit-site/src/components/global-styles/style.scss b/packages/edit-site/src/components/global-styles/style.scss index 5616a068b594c..9ad42b0cc3be3 100644 --- a/packages/edit-site/src/components/global-styles/style.scss +++ b/packages/edit-site/src/components/global-styles/style.scss @@ -146,3 +146,21 @@ $block-preview-height: 150px; max-height: 200px; overflow-y: scroll; } + +.edit-site-global-styles__cs { + margin-bottom: 0.5em; + + .components-heading { + margin: 0 !important; + } +} + +.edit-site-global-styles__cs-content { + display: flex; + gap: 2em; + flex-direction: column; + + .components-button { + margin-left: auto; + } +} From a1bd05d6f7b0ccf1f1f9d5c1200ebdffcf174011 Mon Sep 17 00:00:00 2001 From: Bruno Ribaric Date: Sun, 8 Jan 2023 21:23:17 +0100 Subject: [PATCH 02/13] WIP: Saving custom variations --- .../components/global-styles/global-styles-provider.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/edit-site/src/components/global-styles/global-styles-provider.js b/packages/edit-site/src/components/global-styles/global-styles-provider.js index 94575be45a855..cd0d6de6f2688 100644 --- a/packages/edit-site/src/components/global-styles/global-styles-provider.js +++ b/packages/edit-site/src/components/global-styles/global-styles-provider.js @@ -47,7 +47,10 @@ const cleanEmptyObject = ( object ) => { }; function useGlobalStylesUserConfig() { - const { hasFinishedResolution } = useSelect( coreStore ); + const { + hasFinishedResolution, + __experimentalHasAssociatedVariationChanged, + } = useSelect( coreStore ); const { editEntityRecord, __experimentalAssociatedVariationChanged } = useDispatch( coreStore ); @@ -68,7 +71,7 @@ function useGlobalStylesUserConfig() { : undefined; if ( _associatedStyleId && - ! __experimentalAssociatedVariationChanged() + ! __experimentalHasAssociatedVariationChanged() ) { getEditedEntityRecord( 'root', @@ -98,7 +101,7 @@ function useGlobalStylesUserConfig() { } const associatedStyleFinishedResolution = - __experimentalAssociatedVariationChanged() || + __experimentalHasAssociatedVariationChanged() || hasFinishedResolution( 'getEditedEntityRecord', [ 'root', 'globalStyles', From cb0a3333433a863d33b5c9ef2da9f9356fa05b3d Mon Sep 17 00:00:00 2001 From: Bruno Ribaric Date: Mon, 9 Jan 2023 20:39:58 +0100 Subject: [PATCH 03/13] WIP: Saving styles --- ...class-wp-theme-json-resolver-gutenberg.php | 6 +- ...berg-rest-global-styles-controller-6-2.php | 17 +- packages/core-data/src/actions.js | 60 ++++- .../src/components/global-styles/context.js | 2 + .../global-styles/global-styles-provider.js | 56 ++++- .../src/components/global-styles/hooks.js | 5 +- .../global-styles/screen-style-variations.js | 226 ++++++++++++++---- .../src/components/global-styles/utils.js | 8 + 8 files changed, 305 insertions(+), 75 deletions(-) diff --git a/lib/class-wp-theme-json-resolver-gutenberg.php b/lib/class-wp-theme-json-resolver-gutenberg.php index d8b67faba239e..d915e7a4c2cfe 100644 --- a/lib/class-wp-theme-json-resolver-gutenberg.php +++ b/lib/class-wp-theme-json-resolver-gutenberg.php @@ -530,10 +530,14 @@ public static function associate_user_variation_with_global_styles_post( $id ) { $prev_id = get_post_meta( $current_gs_id, 'associated_user_variation', true ); - if ( empty( $prev_id ) ) { + if ( empty( $prev_id ) && ! empty( $id ) ) { return add_post_meta( $current_gs_id, 'associated_user_variation', $id, true ); } + if ( empty( $id ) ) { + return delete_post_meta( $current_gs_id, 'associated_user_variation' ); + } + return update_post_meta( $current_gs_id, 'associated_user_variation', $id ); } diff --git a/lib/compat/wordpress-6.2/class-gutenberg-rest-global-styles-controller-6-2.php b/lib/compat/wordpress-6.2/class-gutenberg-rest-global-styles-controller-6-2.php index 75241982d6c63..25873c56f7ece 100644 --- a/lib/compat/wordpress-6.2/class-gutenberg-rest-global-styles-controller-6-2.php +++ b/lib/compat/wordpress-6.2/class-gutenberg-rest-global-styles-controller-6-2.php @@ -252,8 +252,13 @@ public function update_item( $request ) { return $post_before; } - if ( ! empty( $request['associated_style_id'] ) ) { - if ( $request['id'] !== WP_Theme_JSON_Resolver_Gutenberg::get_user_global_styles_post_id() ) { + $changes = $this->prepare_item_for_database( $request ); + if ( is_wp_error( $changes ) ) { + return $changes; + } + + if ( isset( $request['associated_style_id'] ) ) { + if ( ( (int) $request['id'] ) !== WP_Theme_JSON_Resolver_Gutenberg::get_user_global_styles_post_id() ) { return new WP_Error( 'rest_cannot_edit', __( 'Sorry, you are not allowed to add an associated style id to this global style.', 'gutenberg' ), @@ -261,7 +266,7 @@ public function update_item( $request ) { ); } - if ( is_numeric( $request['associated_style_id'] ) && $request['associated_style_id'] < 0 ) { + if ( is_numeric( $request['associated_style_id'] ) && $request['associated_style_id'] <= 0 ) { $id = null; } else { $id = $request['associated_style_id']; @@ -270,11 +275,6 @@ public function update_item( $request ) { WP_Theme_JSON_Resolver_Gutenberg::associate_user_variation_with_global_styles_post( $id ); } - $changes = $this->prepare_item_for_database( $request ); - if ( is_wp_error( $changes ) ) { - return $changes; - } - $result = wp_update_post( wp_slash( (array) $changes ), true, false ); if ( is_wp_error( $result ) ) { return $result; @@ -350,6 +350,7 @@ protected function prepare_item_for_database( $request ) { $changes->post_title = $request['title']['raw']; } } + return $changes; } diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index f905cb1cc67c7..bcb3d22950709 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -862,10 +862,10 @@ export function receiveAutosaves( postId, autosaves ) { */ export const __experimentalCreateNewGlobalStylesVariation = ( globalStylesData ) => - async ( { dispatch, resolveSelect } ) => { + async ( { dispatch, resolveSelect, select } ) => { const currentTheme = await resolveSelect.getCurrentTheme(); - await apiFetch( { + const newVariationData = await apiFetch( { path: '/wp/v2/global-styles', method: 'POST', data: globalStylesData, @@ -879,6 +879,25 @@ export const __experimentalCreateNewGlobalStylesVariation = currentTheme.stylesheet, variations ); + + /* eslint-disable dot-notation */ + // Discard changes to the previously associated variation + const globalStylesId = select.__experimentalGetCurrentGlobalStylesId(); + const userConfig = select.getEditedEntityRecord( + 'root', + 'globalStyles', + globalStylesId + ); + if ( userConfig[ 'associated_style_id' ] ) { + dispatch.__experimentalDiscardRecordChanges( + 'root', + 'globalStyles', + userConfig[ 'associated_style_id' ] + ); + } + /* eslint-enable dot-notation */ + + return newVariationData; }; /** @@ -898,3 +917,40 @@ export function __experimentalAssociatedVariationChanged( hasChanged ) { hasChanged, }; } + +/** + * Discards changes in the specified record. + * + * @param {string} kind Entity kind. + * @param {string} name Entity name. + * @param {*} recordId Record ID. + * + */ +export const __experimentalDiscardRecordChanges = + ( kind, name, recordId ) => + ( { dispatch, select } ) => { + const edits = select.getEntityRecordEdits( kind, name, recordId ); + + if ( ! edits ) { + return; + } + + const clearedEdits = Object.keys( edits ).reduce( ( acc, key ) => { + return { + ...acc, + [ key ]: undefined, + }; + }, {} ); + + dispatch( { + type: 'EDIT_ENTITY_RECORD', + kind, + name, + recordId, + edits: clearedEdits, + transientEdits: clearedEdits, + meta: { + isUndo: false, + }, + } ); + }; diff --git a/packages/edit-site/src/components/global-styles/context.js b/packages/edit-site/src/components/global-styles/context.js index 630b5a2b9059d..ec5ef51dd4550 100644 --- a/packages/edit-site/src/components/global-styles/context.js +++ b/packages/edit-site/src/components/global-styles/context.js @@ -8,6 +8,8 @@ export const DEFAULT_GLOBAL_STYLES_CONTEXT = { base: {}, merged: {}, setUserConfig: () => {}, + selectedThemeVariationChanged: false, + setSelectedThemeVariationChanged: () => {}, }; export const GlobalStylesContext = createContext( diff --git a/packages/edit-site/src/components/global-styles/global-styles-provider.js b/packages/edit-site/src/components/global-styles/global-styles-provider.js index cd0d6de6f2688..c3982a908b72c 100644 --- a/packages/edit-site/src/components/global-styles/global-styles-provider.js +++ b/packages/edit-site/src/components/global-styles/global-styles-provider.js @@ -6,7 +6,7 @@ import { mergeWith, isEmpty, mapValues } from 'lodash'; /** * WordPress dependencies */ -import { useMemo, useCallback } from '@wordpress/element'; +import { useMemo, useCallback, useState } from '@wordpress/element'; import { useSelect, useDispatch } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; @@ -51,8 +51,11 @@ function useGlobalStylesUserConfig() { hasFinishedResolution, __experimentalHasAssociatedVariationChanged, } = useSelect( coreStore ); - const { editEntityRecord, __experimentalAssociatedVariationChanged } = - useDispatch( coreStore ); + const { + editEntityRecord, + __experimentalAssociatedVariationChanged, + __experimentalDiscardRecordChanges, + } = useDispatch( coreStore ); const { globalStylesId, isReady, settings, styles, associated_style_id } = useSelect( ( select ) => { @@ -143,14 +146,14 @@ function useGlobalStylesUserConfig() { const currentConfig = { styles: record?.styles ?? {}, settings: record?.settings ?? {}, - associated_style_id: record?.associated_style_id ?? null, + associated_style_id: record?.associated_style_id ?? 0, }; const updatedConfig = callback( currentConfig ); const updatedRecord = { styles: cleanEmptyObject( updatedConfig.styles ) || {}, settings: cleanEmptyObject( updatedConfig.settings ) || {}, associated_style_id: - updatedConfig[ 'associated_style_id' ] || null, + updatedConfig[ 'associated_style_id' ] || 0, }; let associatedStyleIdChanged = false; @@ -174,6 +177,25 @@ function useGlobalStylesUserConfig() { options ); + // If a theme variation is selected, discard any changes made to the + // associated style record + if ( + ! updatedRecord[ 'associated_style_id' ] && + currentConfig[ 'associated_style_id' ] && + hasFinishedResolution( 'getEditedEntityRecord', [ + 'root', + 'globalStyles', + currentConfig[ 'associated_style_id' ], + ] ) + ) { + __experimentalDiscardRecordChanges( + 'root', + 'globalStyles', + currentConfig[ 'associated_style_id' ] + ); + } + + // Also add changes that were made to the user record to the associated record. if ( ! associatedStyleIdChanged && updatedRecord[ 'associated_style_id' ] @@ -225,7 +247,16 @@ function useGlobalStylesUserConfig() { [ globalStylesId ] ); - return [ isReady, config, setConfig ]; + const [ selectedThemeVariationChanged, setSelectedThemeVariationChanged ] = + useState( false ); + + return [ + isReady, + config, + setConfig, + selectedThemeVariationChanged, + setSelectedThemeVariationChanged, + ]; } function useGlobalStylesBaseConfig() { @@ -239,8 +270,13 @@ function useGlobalStylesBaseConfig() { } function useGlobalStylesContext() { - const [ isUserConfigReady, userConfig, setUserConfig ] = - useGlobalStylesUserConfig(); + const [ + isUserConfigReady, + userConfig, + setUserConfig, + selectedThemeVariationChanged, + setSelectedThemeVariationChanged, + ] = useGlobalStylesUserConfig(); const [ isBaseConfigReady, baseConfig ] = useGlobalStylesBaseConfig(); const mergedConfig = useMemo( () => { if ( ! baseConfig || ! userConfig ) { @@ -255,6 +291,8 @@ function useGlobalStylesContext() { base: baseConfig, merged: mergedConfig, setUserConfig, + selectedThemeVariationChanged, + setSelectedThemeVariationChanged, }; }, [ mergedConfig, @@ -263,6 +301,8 @@ function useGlobalStylesContext() { setUserConfig, isUserConfigReady, isBaseConfigReady, + selectedThemeVariationChanged, + setSelectedThemeVariationChanged, ] ); return context; diff --git a/packages/edit-site/src/components/global-styles/hooks.js b/packages/edit-site/src/components/global-styles/hooks.js index 1ea3006f10a81..4aa06d4f8a98a 100644 --- a/packages/edit-site/src/components/global-styles/hooks.js +++ b/packages/edit-site/src/components/global-styles/hooks.js @@ -370,9 +370,8 @@ export function useColorRandomizer( name ) { export function useHasUserModifiedStyles() { const { user } = useContext( GlobalStylesContext ); return ( - Boolean( user ) && - ( Object.keys( user.settings ).length !== 0 || - Object.keys( user.styles ).length !== 0 ) + Object.keys( user.settings ).length > 0 || + Object.keys( user.styles ).length > 0 ); } diff --git a/packages/edit-site/src/components/global-styles/screen-style-variations.js b/packages/edit-site/src/components/global-styles/screen-style-variations.js index 0552a0629d842..5074c74245b5d 100644 --- a/packages/edit-site/src/components/global-styles/screen-style-variations.js +++ b/packages/edit-site/src/components/global-styles/screen-style-variations.js @@ -2,7 +2,6 @@ * External dependencies */ import classnames from 'classnames'; -import fastDeepEqual from 'fast-deep-equal/es6'; /** * WordPress dependencies @@ -46,19 +45,28 @@ import { useCreateNewStyleRecord, useCustomSavedStyles, } from './hooks'; +import { compareVariations } from './utils'; /* eslint-disable dot-notation */ -function compareVariations( a, b ) { - return ( - fastDeepEqual( a.styles, b.styles ) && - fastDeepEqual( a.settings, b.settings ) - ); -} - -function Variation( { variation } ) { +function Variation( { + variation, + selectedThemeVariationChanged, + setSelectedThemeVariationChanged, +} ) { const [ isFocused, setIsFocused ] = useState( false ); const { base, user, setUserConfig } = useContext( GlobalStylesContext ); + const { hasEditsForEntityRecord } = useSelect( coreStore ); + const { globalStyleId } = useSelect( ( select ) => { + return { + globalStyleId: + select( coreStore ).__experimentalGetCurrentGlobalStylesId(), + }; + }, [] ); + + // StylesPreview needs to be wrapped in a custom context so that the styles + // appear correctly. Otherwise, they would be overriden by current user + // settings. const context = useMemo( () => { return { user: { @@ -72,10 +80,33 @@ function Variation( { variation } ) { }, [ variation, base ] ); const selectVariation = () => { + /* eslint-disable no-alert */ + if ( + user[ 'associated_style_id' ] && + hasEditsForEntityRecord( + 'root', + 'globalStyles', + user[ 'associated_style_id' ] + ) && + ! selectedThemeVariationChanged && + hasEditsForEntityRecord( 'root', 'globalStyles', globalStyleId ) && + ! window.confirm( + __( + 'Are you sure you want to switch to this variation? Unsaved changes will be lost.' + ) + ) + ) { + return; + } + /* eslint-enable no-alert */ + + setSelectedThemeVariationChanged( true ); + setUserConfig( () => { return { settings: variation.settings, styles: variation.styles, + associated_style_id: 0, }; } ); }; @@ -92,39 +123,66 @@ function Variation( { variation } ) { }, [ user, variation ] ); return ( - -
setIsFocused( true ) } - onBlur={ () => setIsFocused( false ) } - > -
+
setIsFocused( true ) } + onBlur={ () => setIsFocused( false ) } + > +
+ -
+
- +
); } -function UserVariation( { variation } ) { +function UserVariation( { variation, selectedThemeVariationChanged } ) { const [ isFocused, setIsFocused ] = useState( false ); - const { user, setUserConfig } = useContext( GlobalStylesContext ); + const { base, user, setUserConfig } = useContext( GlobalStylesContext ); const associatedStyleId = user[ 'associated_style_id' ]; + const { hasEditsForEntityRecord } = useSelect( coreStore ); + const { globalStyleId, hasAssociatedVariationChanged } = useSelect( + ( select ) => { + return { + globalStyleId: + select( + coreStore + ).__experimentalGetCurrentGlobalStylesId(), + hasAssociatedVariationChanged: + select( + coreStore + ).__experimentalHasAssociatedVariationChanged(), + }; + }, + [] + ); + // StylesPreview needs to be wrapped in a custom context so that the styles + // appear correctly. Otherwise, they would be overriden by current user + // settings. + const context = useMemo( () => { + return { + user: { + settings: variation.settings ?? {}, + styles: variation.styles ?? {}, + }, + base, + merged: mergeBaseAndUserConfigs( base, variation ), + setUserConfig: () => {}, + }; + }, [ variation, base ] ); const isActive = useMemo( () => variation.id === associatedStyleId, @@ -132,12 +190,32 @@ function UserVariation( { variation } ) { ); const selectVariation = useCallback( () => { + /* eslint-disable no-alert */ + if ( + ! selectedThemeVariationChanged && + ! hasAssociatedVariationChanged && + hasEditsForEntityRecord( 'root', 'globalStyles', globalStyleId ) && + ! window.confirm( + __( + 'Are you sure you want to switch to this variation? Unsaved changes will be lost.' + ) + ) + ) { + return; + } + /* eslint-enable no-alert */ + setUserConfig( () => ( { settings: variation.settings, styles: variation.styles, associated_style_id: variation.id, } ) ); - }, [ variation ] ); + }, [ + variation, + globalStyleId, + hasAssociatedVariationChanged, + selectedThemeVariationChanged, + ] ); const selectOnEnter = ( event ) => { if ( event.keyCode === ENTER ) { @@ -161,11 +239,13 @@ function UserVariation( { variation } ) { onBlur={ () => setIsFocused( false ) } >
- + + +
); @@ -176,12 +256,24 @@ function ScreenStyleVariations() { useState( false ); const [ newStyleName, setNewStyleName ] = useState( '' ); const [ isStyleRecordSaving, setIsStyleRecordSaving ] = useState( false ); - const { user } = useContext( GlobalStylesContext ); - const { - __experimentalHasAssociatedVariationChanged, - getEditedEntityRecord, - } = useSelect( coreStore ); + user, + setSelectedThemeVariationChanged, + selectedThemeVariationChanged, + setUserConfig, + } = useContext( GlobalStylesContext ); + const { __experimentalAssociatedVariationChanged } = + useDispatch( coreStore ); + + const { getEditedEntityRecord } = useSelect( coreStore ); + const { associatedVariationChanged } = useSelect( ( select ) => { + return { + associatedVariationChanged: + select( + coreStore + ).__experimentalHasAssociatedVariationChanged(), + }; + }, [] ); const { variations, mode } = useSelect( ( select ) => { return { variations: @@ -236,21 +328,23 @@ function ScreenStyleVariations() { const createNewStyleRecord = useCreateNewStyleRecord( newStyleName ); - // TODO Wrap in useCallback - // Needs to be done on back button click, otherwise if getEditedEntityRecord is called - // when a variation is clicked, there is an ugly screen re-render that resets navigation. - const handleBackButtonClick = () => { - if ( - user[ 'associated_style_id' ] && - __experimentalHasAssociatedVariationChanged() - ) { + // This functionality needs to be done on back button click, otherwise if + // getEditedEntityRecord is called when a variation is clicked, there is an + // ugly screen re-render that resets navigation. + const handleBackButtonClick = useCallback( () => { + if ( user[ 'associated_style_id' ] && associatedVariationChanged ) { + // Load entity for editing getEditedEntityRecord( 'root', 'globalStyles', user[ 'associated_style_id' ] ); } - }; + + // Reset state + setSelectedThemeVariationChanged( false ); + __experimentalAssociatedVariationChanged( false ); + }, [ associatedVariationChanged, user ] ); return ( <> @@ -284,6 +378,12 @@ function ScreenStyleVariations() { ) ) } @@ -300,7 +400,19 @@ function ScreenStyleVariations() { { __( 'Theme styles' ) } { withEmptyVariation?.map( ( variation, index ) => ( - + ) ) }
@@ -322,7 +434,15 @@ function ScreenStyleVariations() { />