diff --git a/lib/class-wp-theme-json-resolver-gutenberg.php b/lib/class-wp-theme-json-resolver-gutenberg.php index 8733fd13bf2c7..19c3f50e660a0 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,94 @@ public static function get_user_data_from_wp_global_styles( $theme, $create_post return $user_cpt; } + // @codingStandardsIgnoreStart Squiz.Commenting.FunctionComment.ParamCommentFullStop + /** + * 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; + } + // @codingStandardsIgnoreEnd Squiz.Commenting.FunctionComment.ParamCommentFullStop + + /** + * 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 ) && ! 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 ); + } + + /** + * 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 +781,47 @@ public static function get_style_variations() { return $variations; } + /** + * Returns all style variations. + * + * @since 6.2.0 + * + * @return array + */ + public static function get_user_style_variations() { + $stylesheet = get_stylesheet(); + + $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, + '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..26e408e29ebf9 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,7 +16,288 @@ class Gutenberg_REST_Global_Styles_Controller_6_2 extends WP_REST_Global_Styles_ * @return void */ public function register_routes() { - parent::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 ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/themes/(?P[\/\s%\w\.\(\)\[\]\@_\-]+)/variations', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_theme_items' ), + 'permission_callback' => array( $this, 'get_theme_items_permissions_check' ), + 'args' => array( + 'stylesheet' => array( + 'description' => __( 'The theme identifier', 'gutenberg' ), + 'type' => 'string', + ), + ), + ), + ) + ); + + // List themes global styles. + register_rest_route( + $this->namespace, + // The route. + sprintf( + '/%s/themes/(?P%s)', + $this->rest_base, + // Matches theme's directory: `/themes///` or `/themes//`. + // Excludes invalid directory name characters: `/:<>*?"|`. + '[^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?' + ), + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_theme_item' ), + 'permission_callback' => array( $this, 'get_theme_item_permissions_check' ), + 'args' => array( + 'stylesheet' => array( + 'description' => __( 'The theme identifier', 'gutenberg' ), + 'type' => 'string', + 'sanitize_callback' => array( $this, '_sanitize_global_styles_callback' ), + ), + ), + ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\/\w-]+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'id' => array( + 'description' => __( 'The id of a template', 'gutenberg' ), + 'type' => 'string', + 'sanitize_callback' => array( $this, '_sanitize_global_styles_callback' ), + ), + ), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_item' ), + 'permission_callback' => array( $this, 'delete_item_permissions_check' ), + 'args' => array( + 'id' => array( + 'description' => __( 'The id of a template', 'gutenberg' ), + 'type' => 'string', + 'sanitize_callback' => array( $this, '_sanitize_global_styles_callback' ), + ), + ), + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_item' ), + 'permission_callback' => array( $this, 'update_item_permissions_check' ), + 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + } + + /** + * Checks if a given request has access to read all theme global styles configs. + * + * @since 6.0.0 + * + * @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_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + // 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.', 'gutenberg' ), + array( + 'status' => rest_authorization_required_code(), + ) + ); + } + + return true; + } + + /** + * Deletes the given global styles config. + * + * @since 6.2 + * + * @param WP_REST_Request $request The request instance. + * + * @return WP_REST_Response|WP_Error + */ + public function get_items( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $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 delete a single global style. + * + * @since 6.2 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function delete_item_permissions_check( $request ) { + $post = $this->get_post( $request['id'] ); + if ( is_wp_error( $post ) ) { + return $post; + } + + if ( $post && $this->check_delete_permission( $post ) ) { + return new WP_Error( + 'rest_forbidden_context', + __( 'Sorry, you are not allowed to delete this global style.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Deletes the given global styles config. + * + * @since 6.2 + * + * @param WP_REST_Request $request The request instance. + * + * @return WP_REST_Response|WP_Error + */ + public function delete_item( $request ) { + $post = $this->get_post( $request['id'] ); + if ( is_wp_error( $post ) ) { + return $post; + } + + $previous = $this->prepare_item_for_response( $post, $request ); + $result = wp_delete_post( $post->ID, true ); + $response = new WP_REST_Response(); + $response->set_data( + array( + 'deleted' => true, + 'previous' => $previous->get_data(), + ) + ); + + $association_result = true; + + // Delete association if we're deleting an associated style. + if ( WP_Theme_JSON_Resolver_Gutenberg::get_associated_user_variation_id() === $post->ID ) { + $association_result = WP_Theme_JSON_Resolver_Gutenberg::associate_user_variation_with_global_styles_post( null ); + } + + if ( ! $result || ! $association_result ) { + return new WP_Error( + 'rest_cannot_delete', + __( 'The global style cannot be deleted.', 'gutenberg' ), + array( 'status' => 500 ) + ); + } + + return $response; + } + + /** + * 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 ); } /** @@ -67,6 +348,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 ); @@ -109,6 +400,24 @@ public function update_item( $request ) { 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' ), + 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 ); + } + $result = wp_update_post( wp_slash( (array) $changes ), true, false ); if ( is_wp_error( $result ) ) { return $result; @@ -136,10 +445,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(); @@ -178,6 +493,7 @@ protected function prepare_item_for_database( $request ) { $changes->post_title = $request['title']['raw']; } } + return $changes; } @@ -252,4 +568,108 @@ public function get_theme_item( $request ) { return $response; } + + /** + * Checks if a global style can be deleted. + * + * @since 6.2 + * + * @param WP_Post $post Post object. + * @return bool Whether the post can be deleted. + */ + protected function check_delete_permission( $post ) { + $post_type = get_post_type_object( $post->post_type ); + + if ( ! $this->check_is_post_type_allowed( $post_type ) ) { + return false; + } + + return current_user_can( 'delete_post', $post->ID ); + } + + /** + * Checks if a given post type can be viewed or managed. + * + * @since 6.2 + * + * @param WP_Post_Type|string $post_type Post type name or object. + * @return bool Whether the post type is allowed in REST. + */ + protected function check_is_post_type_allowed( $post_type ) { + if ( ! is_object( $post_type ) ) { + $post_type = get_post_type_object( $post_type ); + } + + if ( ! empty( $post_type ) && ! empty( $post_type->show_in_rest ) ) { + return true; + } + + return false; + } + + /** + * 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..4315936bfc0a2 100644 --- a/lib/compat/wordpress-6.2/rest-api.php +++ b/lib/compat/wordpress-6.2/rest-api.php @@ -102,6 +102,15 @@ function gutenberg_register_global_styles_endpoints() { } add_action( 'rest_api_init', 'gutenberg_register_global_styles_endpoints' ); +/** + * Registers the themes REST API routes. + */ +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/package.json b/package.json index 78afdd52d1f71..7de773143ce61 100644 --- a/package.json +++ b/package.json @@ -310,7 +310,9 @@ "test:unit:debug": "wp-scripts --inspect-brk test-unit-js --runInBand --no-cache --verbose --config test/unit/jest.config.js ", "test:unit:profile": "wp-scripts --cpu-prof test-unit-js --runInBand --no-cache --verbose --config test/unit/jest.config.js ", "pretest:unit:php": "wp-env start", + "pretest:unit:php:debug": "wp-env start --xdebug", "test:unit:php": "wp-env run tests-wordpress /var/www/html/wp-content/plugins/gutenberg/vendor/bin/phpunit -c /var/www/html/wp-content/plugins/gutenberg/phpunit.xml.dist --verbose", + "test:unit:php:debug": "wp-env run tests-wordpress /var/www/html/wp-content/plugins/gutenberg/vendor/bin/phpunit -c /var/www/html/wp-content/plugins/gutenberg/phpunit.xml.dist --verbose", "pretest:unit:php:multisite": "wp-env start", "test:unit:php:multisite": "wp-env run tests-wordpress /var/www/html/wp-content/plugins/gutenberg/vendor/bin/phpunit -c /var/www/html/wp-content/plugins/gutenberg/phpunit/multisite.xml --verbose", "test:unit:update": "npm run test:unit -- --updateSnapshot", diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 307e5cef0e229..f2f391b582f80 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -193,6 +193,28 @@ export function __experimentalReceiveThemeGlobalStyleVariations( }; } +/** + * Returns an action object used in signalling that the user global styles variations have been received. + * Ignored from documentation as it's internal to the data store. + * + * @ignore + * + * @param {string} stylesheet Stylesheet. + * @param {Array} variations The global styles variations. + * + * @return {Object} Action object. + */ +export function __experimentalReceiveUserGlobalStyleVariations( + stylesheet, + variations +) { + return { + type: 'RECEIVE_USER_GLOBAL_STYLE_VARIATIONS', + stylesheet, + variations, + }; +} + /** * Returns an action object used in signalling that the index has been received. * @@ -834,3 +856,40 @@ export function receiveAutosaves( postId, autosaves ) { autosaves: Array.isArray( autosaves ) ? autosaves : [ autosaves ], }; } + +/** + * Discards changes in the specified record. + * + * @param {string} kind Entity kind. + * @param {string} name Entity name. + * @param {*} recordId Record ID. + * + */ +export const __experimentalDiscardRecordChanges = + ( kind, name, recordId ) => + ( { dispatch, select } ) => { + const edits = select.getEntityRecordEdits( kind, name, recordId ); + + if ( ! edits ) { + return; + } + + const clearedEdits = Object.keys( edits ).reduce( ( acc, key ) => { + return { + ...acc, + [ key ]: undefined, + }; + }, {} ); + + dispatch( { + type: 'EDIT_ENTITY_RECORD', + kind, + name, + recordId, + edits: clearedEdits, + transientEdits: clearedEdits, + meta: { + isUndo: false, + }, + } ); + }; diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 21ecaff436c72..bdf70a85c37cf 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -187,6 +187,26 @@ export function themeGlobalStyleVariations( state = {}, action ) { return state; } +/** + * Reducer managing the user global styles variations. + * + * @param {Record} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Record} Updated state. + */ +export function userGlobalStyleVariations( state = {}, action ) { + switch ( action.type ) { + case 'RECEIVE_USER_GLOBAL_STYLE_VARIATIONS': + return { + ...state, + [ action.stylesheet ]: action.variations, + }; + } + + return state; +} + /** * Higher Order Reducer for a given entity config. It supports: * @@ -658,4 +678,5 @@ export default combineReducers( { autosaves, blockPatterns, blockPatternCategories, + userGlobalStyleVariations, } ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index b33bb42e65337..3a61e768a1333 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -487,17 +487,30 @@ export const __experimentalGetCurrentThemeBaseGlobalStyles = ); }; -export const __experimentalGetCurrentThemeGlobalStylesVariations = - () => +/** + * @param {string} [author] Variations author. Either 'theme' or 'user'. + */ +export const __experimentalGetGlobalStylesVariations = + ( author = 'theme' ) => async ( { resolveSelect, dispatch } ) => { const currentTheme = await resolveSelect.getCurrentTheme(); - const variations = await apiFetch( { - path: `/wp/v2/global-styles/themes/${ currentTheme.stylesheet }/variations`, - } ); - dispatch.__experimentalReceiveThemeGlobalStyleVariations( - currentTheme.stylesheet, - variations - ); + if ( author === 'theme' ) { + const variations = await apiFetch( { + path: `/wp/v2/global-styles/themes/${ currentTheme.stylesheet }/variations`, + } ); + dispatch.__experimentalReceiveThemeGlobalStyleVariations( + currentTheme.stylesheet, + variations + ); + } else { + const variations = await apiFetch( { + path: `/wp/v2/global-styles`, + } ); + dispatch.__experimentalReceiveUserGlobalStyleVariations( + currentTheme.stylesheet, + variations + ); + } }; export const getBlockPatterns = diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 9998d67728e74..fad741598315c 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -34,7 +34,8 @@ export interface State { embedPreviews: Record< string, { html: string } >; entities: EntitiesState; themeBaseGlobalStyles: Record< string, Object >; - themeGlobalStyleVariations: Record< string, string >; + themeGlobalStyleVariations: Record< string, Object[] >; + userGlobalStyleVariations: Record< string, Object[] >; undo: UndoState; users: UserState; } @@ -1238,20 +1239,27 @@ export function __experimentalGetCurrentThemeBaseGlobalStyles( } /** - * Return the ID of the current global styles object. + * Return global styles variations. * * @param state Data state. + * @param author Variations author. Either 'theme' or 'user'. * - * @return The current global styles ID. + * @return Global styles variations */ -export function __experimentalGetCurrentThemeGlobalStylesVariations( - state: State -): string | null { +export function __experimentalGetGlobalStylesVariations( + state: State, + author: 'theme' | 'user' = 'theme' +): Object[] | null { const currentTheme = getCurrentTheme( state ); if ( ! currentTheme ) { return null; } - return state.themeGlobalStyleVariations[ currentTheme.stylesheet ]; + + if ( author === 'theme' ) { + return state.themeGlobalStyleVariations[ currentTheme.stylesheet ]; + } + + return state.userGlobalStyleVariations[ currentTheme.stylesheet ]; } /** diff --git a/packages/e2e-tests/plugins/global-styles.php b/packages/e2e-tests/plugins/global-styles.php new file mode 100644 index 0000000000000..5f050801a868d --- /dev/null +++ b/packages/e2e-tests/plugins/global-styles.php @@ -0,0 +1,32 @@ + WP_REST_Server::DELETABLE, + 'callback' => function() { + global $wpdb; + $gs_id = WP_Theme_JSON_Resolver_Gutenberg::get_user_global_styles_post_id(); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->get_results( "DELETE FROM {$wpdb->posts} WHERE post_type = 'wp_global_styles' AND id != {$gs_id}" ); + return rest_ensure_response( array( 'deleted' => true ) ); + }, + 'permission_callback' => '__return_true', + ), + ) + ); +} +add_action( 'rest_api_init', 'gutenberg_add_delete_all_global_styles_endpoint' ); diff --git a/packages/edit-site/src/components/global-styles/global-styles-provider.js b/packages/edit-site/src/components/global-styles/global-styles-provider.js index 0813dc63b2cb8..2cdd336869dfb 100644 --- a/packages/edit-site/src/components/global-styles/global-styles-provider.js +++ b/packages/edit-site/src/components/global-styles/global-styles-provider.js @@ -6,7 +6,7 @@ import { mergeWith, isEmpty, mapValues } from 'lodash'; /** * WordPress dependencies */ -import { useMemo, useCallback } from '@wordpress/element'; +import { useMemo, useCallback, useState } from '@wordpress/element'; import { useSelect, useDispatch } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; @@ -15,6 +15,8 @@ import { store as coreStore } from '@wordpress/core-data'; */ import { GlobalStylesContext } from './context'; +/* eslint-disable dot-notation, camelcase */ + function mergeTreesCustomizer( _, srcValue ) { // We only pass as arrays the presets, // in which case we want the new array of values @@ -45,10 +47,15 @@ const cleanEmptyObject = ( object ) => { }; function useGlobalStylesUserConfig() { - const { globalStylesId, isReady, settings, styles } = useSelect( + const { hasFinishedResolution } = useSelect( coreStore ); + const { editEntityRecord, __experimentalDiscardRecordChanges } = + useDispatch( coreStore ); + + const [ isReady, setIsReady ] = useState( false ); + + const { globalStylesId, settings, styles, associated_style_id } = useSelect( ( select ) => { - const { getEditedEntityRecord, hasFinishedResolution } = - select( coreStore ); + const { getEditedEntityRecord } = select( coreStore ); const _globalStylesId = select( coreStore ).__experimentalGetCurrentGlobalStylesId(); const record = _globalStylesId @@ -58,40 +65,74 @@ function useGlobalStylesUserConfig() { _globalStylesId ) : undefined; + const _associatedStyleId = record + ? record[ 'associated_style_id' ] + : undefined; + if ( _associatedStyleId ) { + getEditedEntityRecord( + 'root', + 'globalStyles', + _associatedStyleId + ); + } let hasResolved = false; if ( + ! isReady && hasFinishedResolution( '__experimentalGetCurrentGlobalStylesId' ) ) { - hasResolved = _globalStylesId - ? hasFinishedResolution( 'getEditedEntityRecord', [ + hasResolved = ( () => { + if ( ! _globalStylesId ) { + return false; + } + + const userStyleFinishedResolution = hasFinishedResolution( + 'getEditedEntityRecord', + [ 'root', 'globalStyles', _globalStylesId ] + ); + + if ( ! _associatedStyleId ) { + return userStyleFinishedResolution; + } + + const associatedStyleFinishedResolution = + hasFinishedResolution( 'getEditedEntityRecord', [ 'root', 'globalStyles', - _globalStylesId, - ] ) - : true; + _associatedStyleId, + ] ); + + return ( + userStyleFinishedResolution && + associatedStyleFinishedResolution + ); + } )(); + + if ( hasResolved ) { + setIsReady( true ); + } } return { globalStylesId: _globalStylesId, - isReady: hasResolved, settings: record?.settings, styles: record?.styles, + associated_style_id: _associatedStyleId, }; }, [] ); const { getEditedEntityRecord } = useSelect( coreStore ); - const { editEntityRecord } = useDispatch( coreStore ); const config = useMemo( () => { return { settings: settings ?? {}, styles: styles ?? {}, + associated_style_id: associated_style_id ?? null, }; - }, [ settings, styles ] ); + }, [ settings, styles, associated_style_id ] ); const setConfig = useCallback( ( callback, options = {} ) => { @@ -103,18 +144,86 @@ function useGlobalStylesUserConfig() { const currentConfig = { styles: record?.styles ?? {}, settings: record?.settings ?? {}, + associated_style_id: record?.associated_style_id ?? 0, }; const updatedConfig = callback( currentConfig ); + const updatedRecord = { + styles: cleanEmptyObject( updatedConfig.styles ) || {}, + settings: cleanEmptyObject( updatedConfig.settings ) || {}, + associated_style_id: + updatedConfig[ 'associated_style_id' ] || 0, + }; + + let associatedStyleIdChanged = false; + + if ( + currentConfig[ 'associated_style_id' ] !== + updatedRecord[ 'associated_style_id' ] + ) { + associatedStyleIdChanged = true; + __experimentalDiscardRecordChanges( + 'root', + 'globalStyles', + currentConfig[ 'associated_style_id' ] + ); + } + editEntityRecord( 'root', 'globalStyles', globalStylesId, - { - styles: cleanEmptyObject( updatedConfig.styles ) || {}, - settings: cleanEmptyObject( updatedConfig.settings ) || {}, - }, + updatedRecord, options ); + + // Also add changes that were made to the user record to the associated record. + if ( + ! associatedStyleIdChanged && + updatedRecord[ 'associated_style_id' ] + ) { + if ( + ( ! hasFinishedResolution( 'getEditedEntityRecord' ), + [ + 'root', + 'globalStyles', + updatedRecord[ 'associated_style_id' ], + ] ) + ) { + const intervalId = setInterval( () => { + if ( + ( hasFinishedResolution( 'getEditedEntityRecord' ), + [ + 'root', + 'globalStyles', + updatedRecord[ 'associated_style_id' ], + ] ) + ) { + editEntityRecord( + 'root', + 'globalStyles', + updatedRecord[ 'associated_style_id' ], + { + settings: updatedRecord.settings, + styles: updatedRecord.styles, + }, + options + ); + clearInterval( intervalId ); + } + }, 500 ); + } else { + editEntityRecord( + 'root', + 'globalStyles', + updatedRecord[ 'associated_style_id' ], + { + settings: updatedRecord.settings, + styles: updatedRecord.styles, + }, + options + ); + } + } }, [ globalStylesId ] ); @@ -162,6 +271,8 @@ function useGlobalStylesContext() { return context; } +/* eslint-enable dot-notation, camelcase */ + export function GlobalStylesProvider( { children } ) { const context = useGlobalStylesContext(); if ( ! context.isReady ) { diff --git a/packages/edit-site/src/components/global-styles/hooks.js b/packages/edit-site/src/components/global-styles/hooks.js index c767f1a488cbf..7d800551ac64e 100644 --- a/packages/edit-site/src/components/global-styles/hooks.js +++ b/packages/edit-site/src/components/global-styles/hooks.js @@ -16,11 +16,17 @@ import { __EXPERIMENTAL_PATHS_WITH_MERGE as PATHS_WITH_MERGE, __EXPERIMENTAL_STYLE_PROPERTY as STYLE_PROPERTY, } from '@wordpress/blocks'; +import { store as coreStore, useEntityRecords } from '@wordpress/core-data'; +import { useSelect, useDispatch } from '@wordpress/data'; /** * Internal dependencies */ -import { getValueFromVariable, getPresetVariableFromValue } from './utils'; +import { + getValueFromVariable, + getPresetVariableFromValue, + compareVariations, +} from './utils'; import { GlobalStylesContext } from './context'; // Enable colord's a11y plugin. @@ -364,3 +370,77 @@ export function useColorRandomizer( name ) { ? [ randomizeColors ] : []; } + +export function useHasUserModifiedStyles() { + const { user } = useContext( GlobalStylesContext ); + return ( + Object.keys( user.settings ).length > 0 || + Object.keys( user.styles ).length > 0 + ); +} + +export function useCreateNewStyleRecord( title ) { + const { saveEntityRecord } = useDispatch( coreStore ); + const { user } = useContext( GlobalStylesContext ); + const callback = useCallback( () => { + const recordData = { + ...user, + title, + }; + /* eslint-disable dot-notation */ + delete recordData[ 'associated_style_id' ]; + delete recordData[ 'id' ]; + /* eslint-enable dot-notation */ + return saveEntityRecord( 'root', 'globalStyles', recordData ).then( + ( rawVariation ) => { + return { + ...rawVariation, + title: rawVariation?.title?.rendered, + }; + } + ); + }, [ title, user ] ); + return callback; +} + +export function useCustomSavedStyles() { + const { globalStylesId } = useSelect( ( select ) => { + return { + globalStylesId: + select( coreStore ).__experimentalGetCurrentGlobalStylesId(), + }; + }, [] ); + const { records: variations } = useEntityRecords( 'root', 'globalStyles' ); + + const customVariations = useMemo( () => { + return ( + variations + ?.filter( ( variation ) => variation.id !== globalStylesId ) + ?.map( ( variation ) => { + let newVariation = variation; + if ( variation?.title?.rendered !== undefined ) { + newVariation = { + ...variation, + title: variation.title.rendered, + }; + } + + return newVariation; + } ) || [] + ); + }, [ globalStylesId, variations ] ); + + return customVariations; +} + +export function useUserChangesMatchAnyVariation( variations ) { + const { user } = useContext( GlobalStylesContext ); + const matches = useMemo( + () => + variations?.some( ( variation ) => + compareVariations( user, variation ) + ), + [ user, variations ] + ); + return matches; +} diff --git a/packages/edit-site/src/components/global-styles/screen-root.js b/packages/edit-site/src/components/global-styles/screen-root.js index 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..810c736d3c49b 100644 --- a/packages/edit-site/src/components/global-styles/screen-style-variations.js +++ b/packages/edit-site/src/components/global-styles/screen-style-variations.js @@ -2,7 +2,6 @@ * External dependencies */ import classnames from 'classnames'; -import fastDeepEqual from 'fast-deep-equal/es6'; /** * WordPress dependencies @@ -11,6 +10,7 @@ import { store as coreStore } from '@wordpress/core-data'; import { useSelect, useDispatch } from '@wordpress/data'; import { useMemo, + useCallback, useContext, useState, useEffect, @@ -18,12 +18,23 @@ import { } from '@wordpress/element'; import { ENTER } from '@wordpress/keycodes'; import { - __experimentalGrid as Grid, Card, CardBody, + CardDivider, + Button, + Modal, + MenuGroup, + MenuItem, + __experimentalHeading as Heading, + __experimentalText as Text, + __experimentalHStack as HStack, + __experimentalGrid as Grid, + __experimentalInputControl as InputControl, } from '@wordpress/components'; +import { plus } from '@wordpress/icons'; import { __ } from '@wordpress/i18n'; import { store as blockEditorStore } from '@wordpress/block-editor'; +import { MoreMenuDropdown } from '@wordpress/interface'; /** * Internal dependencies @@ -32,17 +43,30 @@ import { mergeBaseAndUserConfigs } from './global-styles-provider'; import { GlobalStylesContext } from './context'; import StylesPreview from './preview'; import ScreenHeader from './header'; +import { + useHasUserModifiedStyles, + useCreateNewStyleRecord, + useCustomSavedStyles, + useUserChangesMatchAnyVariation, +} from './hooks'; +import { compareVariations } from './utils'; -function compareVariations( a, b ) { - return ( - fastDeepEqual( a.styles, b.styles ) && - fastDeepEqual( a.settings, b.settings ) - ); -} +/* eslint-disable dot-notation */ -function Variation( { variation } ) { +function Variation( { variation, userChangesMatchAnyVariation } ) { const [ isFocused, setIsFocused ] = useState( false ); const { base, user, setUserConfig } = useContext( GlobalStylesContext ); + const { hasEditsForEntityRecord } = useSelect( coreStore ); + const { globalStyleId } = useSelect( ( select ) => { + return { + globalStyleId: + select( coreStore ).__experimentalGetCurrentGlobalStylesId(), + }; + }, [] ); + + // StylesPreview needs to be wrapped in a custom context so that the styles + // appear correctly. Otherwise, they would be overriden by current user + // settings. const context = useMemo( () => { return { user: { @@ -56,10 +80,25 @@ function Variation( { variation } ) { }, [ variation, base ] ); const selectVariation = () => { + /* eslint-disable no-alert */ + if ( + ! userChangesMatchAnyVariation && + hasEditsForEntityRecord( 'root', 'globalStyles', globalStyleId ) && + ! window.confirm( + __( + 'Are you sure you want to switch to this variation? Unsaved changes will be lost.' + ) + ) + ) { + return; + } + /* eslint-enable no-alert */ + setUserConfig( () => { return { settings: variation.settings, styles: variation.styles, + associated_style_id: 0, }; } ); }; @@ -76,42 +115,175 @@ function Variation( { variation } ) { }, [ user, variation ] ); return ( - -
setIsFocused( true ) } - onBlur={ () => setIsFocused( false ) } - > -
+
setIsFocused( true ) } + onBlur={ () => setIsFocused( false ) } + > +
+ -
+
- +
+ ); +} + +function UserVariation( { variation, userChangesMatchAnyVariation } ) { + const [ isFocused, setIsFocused ] = useState( false ); + const { base, user, setUserConfig } = useContext( GlobalStylesContext ); + const associatedStyleId = user[ 'associated_style_id' ]; + const { hasEditsForEntityRecord } = useSelect( coreStore ); + const { deleteEntityRecord } = useDispatch( coreStore ); + const { globalStyleId } = useSelect( ( select ) => { + return { + globalStyleId: + select( coreStore ).__experimentalGetCurrentGlobalStylesId(), + }; + }, [] ); + + const isMoreMenuClick = useCallback( ( e ) => { + if ( + e.target.closest( '.components-dropdown-menu__toggle' ) || + e.target.closest( '.components-menu-item__button' ) + ) { + return true; + } + + return false; + }, [] ); + + // StylesPreview needs to be wrapped in a custom context so that the styles + // appear correctly. Otherwise, they would be overriden by current user + // settings. + const context = useMemo( () => { + return { + user: { + settings: variation.settings ?? {}, + styles: variation.styles ?? {}, + }, + base, + merged: mergeBaseAndUserConfigs( base, variation ), + setUserConfig: () => {}, + }; + }, [ variation, base ] ); + + const isActive = useMemo( + () => variation.id === associatedStyleId, + [ variation, associatedStyleId ] + ); + + const selectVariation = useCallback( + ( e ) => { + if ( isMoreMenuClick( e ) ) { + return; + } + + /* eslint-disable no-alert */ + if ( + ! userChangesMatchAnyVariation && + hasEditsForEntityRecord( + 'root', + 'globalStyles', + globalStyleId + ) && + ! window.confirm( + __( + 'Are you sure you want to switch to this variation? Unsaved changes will be lost.' + ) + ) + ) { + return; + } + /* eslint-enable no-alert */ + + setUserConfig( () => ( { + settings: variation.settings, + styles: variation.styles, + associated_style_id: variation.id, + } ) ); + }, + [ variation, globalStyleId, userChangesMatchAnyVariation ] + ); + + const selectOnEnter = ( event ) => { + if ( event.keyCode === ENTER ) { + event.preventDefault(); + selectVariation(); + } + }; + + const deleteStyleHandler = useCallback( () => { + // If this is the associated variation, remove the association + if ( associatedStyleId === variation.id ) { + setUserConfig( ( currentConfig ) => ( { + ...currentConfig, + associated_style_id: 0, + } ) ); + } + + deleteEntityRecord( 'root', 'globalStyles', variation.id ); + }, [ variation, associatedStyleId ] ); + + return ( +
setIsFocused( true ) } + onBlur={ () => setIsFocused( false ) } + > +
+ + + +
+ + { () => ( + + + { __( 'Delete style' ) } + + + ) } + +
); } function ScreenStyleVariations() { + const [ createNewVariationModalOpen, setCreateNewVariationModalOpen ] = + useState( false ); + const [ newStyleName, setNewStyleName ] = useState( '' ); + const [ isStyleRecordSaving, setIsStyleRecordSaving ] = useState( false ); + const { setUserConfig } = useContext( GlobalStylesContext ); + const { variations, mode } = useSelect( ( select ) => { return { variations: - select( - coreStore - ).__experimentalGetCurrentThemeGlobalStylesVariations(), + select( coreStore ).__experimentalGetGlobalStylesVariations(), mode: select( blockEditorStore ).__unstableGetEditorMode(), }; @@ -132,6 +304,22 @@ function ScreenStyleVariations() { ]; }, [ variations ] ); + const hasUserModifiedStyles = useHasUserModifiedStyles(); + const userVariations = useCustomSavedStyles(); + const allVariations = useMemo( () => { + const ret = []; + if ( Array.isArray( withEmptyVariation ) ) { + ret.push( ...withEmptyVariation ); + } + if ( Array.isArray( userVariations ) ) { + ret.push( ...userVariations ); + } + return ret; + }, [ withEmptyVariation, userVariations ] ); + + const userChangesMatchAnyVariation = + useUserChangesMatchAnyVariation( allVariations ); + const { __unstableSetEditorMode } = useDispatch( blockEditorStore ); const shouldRevertInitialMode = useRef( null ); useEffect( () => { @@ -157,6 +345,8 @@ function ScreenStyleVariations() { } }, [] ); + const createNewStyleRecord = useCreateNewStyleRecord( newStyleName ); + return ( <> + + + + { __( 'Custom styles' ) } + +
+ + ) } ); } +/* eslint-enable dot-notation */ + export default ScreenStyleVariations; diff --git a/packages/edit-site/src/components/global-styles/style.scss b/packages/edit-site/src/components/global-styles/style.scss index 5616a068b594c..03c9c7c6e9f5c 100644 --- a/packages/edit-site/src/components/global-styles/style.scss +++ b/packages/edit-site/src/components/global-styles/style.scss @@ -90,6 +90,8 @@ $block-preview-height: 150px; .edit-site-global-styles-variations_item { box-sizing: border-box; + display: flex; + flex-direction: column; .edit-site-global-styles-variations_item-preview { padding: $border-width * 2; @@ -108,6 +110,16 @@ $block-preview-height: 150px; &:focus .edit-site-global-styles-variations_item-preview { border: var(--wp-admin-theme-color) var(--wp-admin-border-width-focus) solid; } + + .components-dropdown { + margin-left: auto; + } + + .components-dropdown-menu__toggle { + > svg { + transform: rotate(90deg); + } + } } .edit-site-global-styles-icon-with-current-color { @@ -146,3 +158,21 @@ $block-preview-height: 150px; max-height: 200px; overflow-y: scroll; } + +.edit-site-global-styles__cs { + margin-bottom: 0.5em; + + .components-heading { + margin: 0 !important; + } +} + +.edit-site-global-styles__cs-content { + display: flex; + gap: 2em; + flex-direction: column; + + .components-button { + margin-left: auto; + } +} diff --git a/packages/edit-site/src/components/global-styles/utils.js b/packages/edit-site/src/components/global-styles/utils.js index 14f3b86829417..a1b3f9c21576b 100644 --- a/packages/edit-site/src/components/global-styles/utils.js +++ b/packages/edit-site/src/components/global-styles/utils.js @@ -2,6 +2,7 @@ * External dependencies */ import { get } from 'lodash'; +import fastDeepEqual from 'fast-deep-equal/es6'; /** * Internal dependencies @@ -333,3 +334,26 @@ export function scopeSelector( scope, selector ) { return selectorsScoped.join( ', ' ); } + +export function compareVariations( a, b ) { + if ( ! a.styles ) { + a.styles = {}; + } + + if ( ! a.settings ) { + a.settings = {}; + } + + if ( ! b.styles ) { + b.styles = {}; + } + + if ( ! b.settings ) { + b.settings = {}; + } + + return ( + fastDeepEqual( a.styles, b.styles ) && + fastDeepEqual( a.settings, b.settings ) + ); +} diff --git a/packages/env/lib/build-docker-compose-config.js b/packages/env/lib/build-docker-compose-config.js index ff85ba807f00a..a348be0628475 100644 --- a/packages/env/lib/build-docker-compose-config.js +++ b/packages/env/lib/build-docker-compose-config.js @@ -155,9 +155,6 @@ module.exports = function buildDockerComposeConfig( config ) { const developmentWpImage = `wordpress${ developmentPhpVersion ? ':php' + developmentPhpVersion : '' }`; - const testsWpImage = `wordpress${ - testsPhpVersion ? ':php' + testsPhpVersion : '' - }`; // Set the WordPress CLI images with the PHP version tag. const developmentWpCliImage = `wordpress:cli${ ! developmentPhpVersion || developmentPhpVersion.length === 0 @@ -237,8 +234,8 @@ module.exports = function buildDockerComposeConfig( config ) { volumes: developmentMounts, }, 'tests-wordpress': { + build: '.', depends_on: [ 'tests-mysql' ], - image: testsWpImage, ports: [ testsPorts ], environment: { ...dbEnv.credentials, diff --git a/packages/env/lib/init-config.js b/packages/env/lib/init-config.js index 8bcb27e207b7a..38073df35b197 100644 --- a/packages/env/lib/init-config.js +++ b/packages/env/lib/init-config.js @@ -169,10 +169,12 @@ function installXdebug( enableXdebug ) { return ` # Install Xdebug: +RUN apt-get update -y && apt-get -y install iproute2 RUN if [ -z "$(pecl list | grep xdebug)" ] ; then pecl install xdebug ; fi RUN docker-php-ext-enable xdebug RUN echo 'xdebug.start_with_request=yes' >> /usr/local/etc/php/php.ini RUN echo 'xdebug.mode=${ enableXdebug }' >> /usr/local/etc/php/php.ini RUN echo '${ clientDetectSettings }' >> /usr/local/etc/php/php.ini +RUN HOST_IP=$(ip route | awk '/default/ { print $3 }'); echo "xdebug.client_host=\"$HOST_IP\"" >> /usr/local/etc/php/php.ini `; } diff --git a/phpunit/class-gutenberg-rest-global-styles-controller-test.php b/phpunit/class-gutenberg-rest-global-styles-controller-test.php index 01063aa7e51bc..eba7910dad598 100644 --- a/phpunit/class-gutenberg-rest-global-styles-controller-test.php +++ b/phpunit/class-gutenberg-rest-global-styles-controller-test.php @@ -24,7 +24,6 @@ private function find_and_normalize_global_styles_by_id( $global_styles, $id ) { public function set_up() { parent::set_up(); - switch_theme( 'emptytheme' ); } /** @@ -38,20 +37,11 @@ public static function wpSetupBeforeClass( $factory ) { 'role' => 'administrator', ) ); + + switch_theme( 'emptytheme' ); + // This creates the global styles for the current theme. - self::$global_styles_id = wp_insert_post( - array( - 'post_content' => '{"version": ' . WP_Theme_JSON_Gutenberg::LATEST_SCHEMA . ', "isGlobalStylesUserThemeJSON": true }', - 'post_status' => 'publish', - 'post_title' => __( 'Custom Styles', 'default' ), - 'post_type' => 'wp_global_styles', - 'post_name' => 'wp-global-styles-emptytheme', - 'tax_input' => array( - 'wp_theme' => 'emptytheme', - ), - ), - true - ); + self::$global_styles_id = WP_Theme_JSON_Resolver_Gutenberg::get_user_global_styles_post_id(); } @@ -129,20 +119,44 @@ public function test_get_item() { $this->assertEquals( array( - 'id' => self::$global_styles_id, - 'title' => array( + 'id' => self::$global_styles_id, + 'title' => array( 'raw' => 'Custom Styles', 'rendered' => 'Custom Styles', ), - 'settings' => new stdClass(), - 'styles' => new stdClass(), + 'settings' => new stdClass(), + 'styles' => new stdClass(), + 'associated_style_id' => null, ), $data ); } public function test_create_item() { - $this->markTestIncomplete(); + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/global-styles' ); + $request->set_body_params( + array( + 'title' => 'Custom user variation', + 'settings' => new stdClass(), + 'styles' => new stdClass(), + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + unset( $data['_links'] ); + + $this->assertEquals( + array( + 'title' => array( + 'raw' => 'Custom user variation', + 'rendered' => 'Custom user variation', + ), + ), + array( + 'title' => $data['title'], + ) + ); } public function test_update_item() { @@ -159,7 +173,23 @@ public function test_update_item() { } public function test_delete_item() { - $this->markTestIncomplete(); + wp_set_current_user( self::$admin_id ); + $post_id = WP_Theme_JSON_Resolver_Gutenberg::add_user_global_styles_variation( + array( + 'title' => 'Title', + 'global_styles' => \wp_json_encode( + array( + 'settings' => new stdClass, + 'styles' => new stdClass, + ) + ), + 'stylesheet' => get_stylesheet(), + ) + ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/global-styles/' . $post_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertTrue( $data['deleted'] ); } public function test_prepare_item() { diff --git a/test/e2e/specs/site-editor/style-variations.spec.js b/test/e2e/specs/site-editor/style-variations.spec.js index 925be3780c8fd..b006c57ddc195 100644 --- a/test/e2e/specs/site-editor/style-variations.spec.js +++ b/test/e2e/specs/site-editor/style-variations.spec.js @@ -4,8 +4,8 @@ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); test.use( { - siteEditorStyleVariations: async ( { page }, use ) => { - await use( new SiteEditorStyleVariations( { page } ) ); + siteEditorStyleVariations: async ( { page, requestUtils }, use ) => { + await use( new SiteEditorStyleVariations( { page, requestUtils } ) ); }, } ); @@ -14,19 +14,34 @@ test.describe( 'Global styles variations', () => { await requestUtils.activateTheme( 'gutenberg-test-themes/style-variations' ); + await requestUtils.activatePlugin( + 'gutenberg-test-plugin-global-styles' + ); await requestUtils.deleteAllTemplates( 'wp_template' ); await requestUtils.deleteAllTemplates( 'wp_template_part' ); + // Delete all user global styles + await requestUtils.rest( { + method: 'DELETE', + path: '/wp/v2/delete-all-global-styles', + } ); } ); test.afterEach( async ( { requestUtils } ) => { await Promise.all( [ requestUtils.deleteAllTemplates( 'wp_template' ), requestUtils.deleteAllTemplates( 'wp_template_part' ), + requestUtils.rest( { + method: 'DELETE', + path: '/wp/v2/delete-all-global-styles', + } ), ] ); } ); test.afterAll( async ( { requestUtils } ) => { await requestUtils.activateTheme( 'twentytwentyone' ); + await requestUtils.deactivatePlugin( + 'gutenberg-test-plugin-global-styles' + ); } ); test( 'should have three variations available with the first one being active', async ( { @@ -210,6 +225,106 @@ test.describe( 'Global styles variations', () => { 'rgb(255, 239, 11)' ); } ); + + test( 'can create custom style variations', async ( { + admin, + page, + siteEditorStyleVariations, + siteEditor, + } ) => { + await admin.visitSiteEditor( { + postId: 'gutenberg-test-themes/style-variations//index', + postType: 'wp_template', + } ); + await siteEditor.enterEditMode(); + + // Assert no custom styles yet. + await siteEditorStyleVariations.browseStyles(); + await page.getByText( 'No custom styles yet.' ).click(); + + // Change background color + await page + .getByRole( 'button', { name: 'Navigate to the previous view' } ) + .click(); + await page.getByRole( 'button', { name: 'Colors styles' } ).click(); + await page + .getByRole( 'button', { name: 'Colors background styles' } ) + .click(); + await page + .getByRole( 'button', { name: 'Color: Cyan bluish gray' } ) + .click(); + + // Create new style + await page + .getByRole( 'button', { name: 'Navigate to the previous view' } ) + .click(); + await page + .getByRole( 'button', { name: 'Navigate to the previous view' } ) + .click(); + await page.getByRole( 'button', { name: 'Browse styles' } ).click(); + await page + .locator( + '.components-card__body > .components-flex > .components-button' + ) + .click(); + await page.getByLabel( 'Style name' ).click(); + await page.getByLabel( 'Style name' ).fill( 'My custom style' ); + await page.getByRole( 'button', { name: 'Create' } ).click(); + + // Check that the new style exists + await page.getByRole( 'button', { name: 'My custom style' } ).click(); + } ); + + test( 'can delete custom style variations', async ( { + admin, + page, + siteEditorStyleVariations, + siteEditor, + } ) => { + await admin.visitSiteEditor( { + postId: 'gutenberg-test-themes/style-variations//index', + postType: 'wp_template', + } ); + await siteEditor.enterEditMode(); + await siteEditorStyleVariations.disableWelcomeGuide(); + + // Change background color + await page.getByRole( 'button', { name: 'Styles' } ).click(); + await page.getByRole( 'button', { name: 'Colors styles' } ).click(); + await page + .getByRole( 'button', { name: 'Colors background styles' } ) + .click(); + await page + .getByRole( 'button', { name: 'Color: Cyan bluish gray' } ) + .click(); + + // Create new style + await page + .getByRole( 'button', { name: 'Navigate to the previous view' } ) + .click(); + await page + .getByRole( 'button', { name: 'Navigate to the previous view' } ) + .click(); + await page.getByRole( 'button', { name: 'Browse styles' } ).click(); + await page + .locator( + '.components-card__body > .components-flex > .components-button' + ) + .click(); + await page.getByLabel( 'Style name' ).click(); + await page.getByLabel( 'Style name' ).fill( 'My custom style' ); + await page.getByRole( 'button', { name: 'Create' } ).click(); + + // Delete the style + await page + .getByRole( 'button', { name: 'My custom style' } ) + .getByRole( 'button', { name: 'Options' } ) + .click(); + await page.getByRole( 'menuitem', { name: 'Delete style' } ).click(); + + // Check that there are no custom styles + await page.getByText( 'No custom styles yet.' ).click(); + } ); } ); class SiteEditorStyleVariations {