From a1388d4adf31e42e2ffeafb8fb7a5ae66bf8a511 Mon Sep 17 00:00:00 2001 From: Aagam Shah Date: Mon, 26 Jan 2026 15:14:41 +0530 Subject: [PATCH 01/36] Add experimental Content Guidelines feature Introduces a new experimental feature that allows site owners to define editorial guidelines for AI-generated content. The feature includes: - Custom post type (wp_guidelines) for storing guidelines data - REST API endpoints under __experimental/content-guidelines - Admin settings page at Settings > Content Guidelines - Five guideline categories: Copy, Images, Site, Blocks, Other - Block-specific guidelines with dropdown selector for all block types - Draft/Published workflow with revision history - Redux store for state management The feature is gated behind the 'gutenberg-content-guidelines' experiment flag. Co-Authored-By: Claude Opus 4.5 --- .../content-guidelines/class-admin-page.php | 96 +++ .../content-guidelines/class-post-type.php | 63 ++ .../class-rest-controller.php | 556 ++++++++++++++++++ lib/experimental/content-guidelines/index.php | 30 + lib/experiments-page.php | 12 + lib/load.php | 5 + package-lock.json | 39 +- packages/content-guidelines/package.json | 59 ++ .../admin-page/block-guidelines-panel.tsx | 213 +++++++ .../components/admin-page/category-panel.tsx | 41 ++ .../src/components/admin-page/index.tsx | 190 ++++++ .../admin-page/publish-controls.tsx | 65 ++ .../components/admin-page/revision-list.tsx | 116 ++++ packages/content-guidelines/src/index.tsx | 23 + .../content-guidelines/src/store/actions.ts | 155 +++++ .../content-guidelines/src/store/constants.ts | 80 +++ .../content-guidelines/src/store/index.ts | 40 ++ .../content-guidelines/src/store/reducer.ts | 131 +++++ .../content-guidelines/src/store/selectors.ts | 88 +++ packages/content-guidelines/src/style.scss | 192 ++++++ packages/content-guidelines/tsconfig.json | 15 + tsconfig.json | 1 + 22 files changed, 2200 insertions(+), 10 deletions(-) create mode 100644 lib/experimental/content-guidelines/class-admin-page.php create mode 100644 lib/experimental/content-guidelines/class-post-type.php create mode 100644 lib/experimental/content-guidelines/class-rest-controller.php create mode 100644 lib/experimental/content-guidelines/index.php create mode 100644 packages/content-guidelines/package.json create mode 100644 packages/content-guidelines/src/components/admin-page/block-guidelines-panel.tsx create mode 100644 packages/content-guidelines/src/components/admin-page/category-panel.tsx create mode 100644 packages/content-guidelines/src/components/admin-page/index.tsx create mode 100644 packages/content-guidelines/src/components/admin-page/publish-controls.tsx create mode 100644 packages/content-guidelines/src/components/admin-page/revision-list.tsx create mode 100644 packages/content-guidelines/src/index.tsx create mode 100644 packages/content-guidelines/src/store/actions.ts create mode 100644 packages/content-guidelines/src/store/constants.ts create mode 100644 packages/content-guidelines/src/store/index.ts create mode 100644 packages/content-guidelines/src/store/reducer.ts create mode 100644 packages/content-guidelines/src/store/selectors.ts create mode 100644 packages/content-guidelines/src/style.scss create mode 100644 packages/content-guidelines/tsconfig.json diff --git a/lib/experimental/content-guidelines/class-admin-page.php b/lib/experimental/content-guidelines/class-admin-page.php new file mode 100644 index 00000000000000..96a7a1d672f471 --- /dev/null +++ b/lib/experimental/content-guidelines/class-admin-page.php @@ -0,0 +1,96 @@ +'; + } + + /** + * Enqueue scripts and styles for the admin page. + * + * @param string $hook The current admin page hook. + */ + public static function enqueue_scripts( $hook ) { + if ( 'settings_page_content-guidelines' !== $hook ) { + return; + } + + $asset_file = gutenberg_dir_path() . 'build/scripts/content-guidelines/index.min.asset.php'; + + if ( ! file_exists( $asset_file ) ) { + return; + } + + $asset = include $asset_file; + + // Ensure blocks are registered so we can list them in the UI. + $block_registry = WP_Block_Type_Registry::get_instance(); + if ( empty( $block_registry->get_all_registered() ) ) { + // Register core blocks if not already registered. + if ( function_exists( 'register_core_block_types_from_metadata' ) ) { + register_core_block_types_from_metadata(); + } + } + + // Add block library as dependency to ensure blocks store is populated. + $dependencies = $asset['dependencies']; + $dependencies[] = 'wp-block-library'; + + wp_enqueue_script( + 'gutenberg-content-guidelines', + gutenberg_url( 'build/scripts/content-guidelines/index.min.js' ), + $dependencies, + $asset['version'], + true + ); + + $style_path = gutenberg_dir_path() . 'build/styles/content-guidelines/style.css'; + if ( file_exists( $style_path ) ) { + wp_enqueue_style( + 'gutenberg-content-guidelines', + gutenberg_url( 'build/styles/content-guidelines/style.css' ), + array( 'wp-components' ), + $asset['version'] + ); + } + + // Add inline script data for REST API. + wp_localize_script( + 'gutenberg-content-guidelines', + 'contentGuidelinesSettings', + array( + 'restUrl' => rest_url( '__experimental/content-guidelines' ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + ) + ); + } +} diff --git a/lib/experimental/content-guidelines/class-post-type.php b/lib/experimental/content-guidelines/class-post-type.php new file mode 100644 index 00000000000000..e0acff824f41ab --- /dev/null +++ b/lib/experimental/content-guidelines/class-post-type.php @@ -0,0 +1,63 @@ + array( + 'name' => __( 'Content Guidelines', 'gutenberg' ), + 'singular_name' => __( 'Content Guidelines', 'gutenberg' ), + ), + 'public' => false, + 'publicly_queryable' => false, + 'show_ui' => false, + 'show_in_menu' => false, + 'show_in_rest' => true, + 'rest_namespace' => '__experimental', + 'rest_base' => 'content-guidelines', + 'rest_controller_class' => 'Gutenberg_Content_Guidelines_REST_Controller', + 'capability_type' => 'post', + 'capabilities' => array( + 'read' => 'edit_posts', + 'create_posts' => 'manage_options', + 'edit_posts' => 'manage_options', + 'edit_published_posts' => 'manage_options', + 'delete_posts' => 'manage_options', + 'delete_published_posts' => 'manage_options', + 'edit_others_posts' => 'manage_options', + 'delete_others_posts' => 'manage_options', + 'publish_posts' => 'manage_options', + ), + 'map_meta_cap' => true, + 'supports' => array( 'revisions' ), + 'hierarchical' => false, + 'has_archive' => false, + 'rewrite' => false, + 'query_var' => false, + ); + + register_post_type( self::POST_TYPE, $args ); + } +} diff --git a/lib/experimental/content-guidelines/class-rest-controller.php b/lib/experimental/content-guidelines/class-rest-controller.php new file mode 100644 index 00000000000000..8838b38d5c7ea0 --- /dev/null +++ b/lib/experimental/content-guidelines/class-rest-controller.php @@ -0,0 +1,556 @@ +namespace = '__experimental'; + $this->rest_base = 'content-guidelines'; + } + + /** + * Registers the routes for the objects of the controller. + */ + public function register_routes() { + + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + ), + 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 . '/(?P[\d]+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item_by_id' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the guidelines.', 'gutenberg' ), + 'type' => 'integer', + ), + ), + ), + 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 ), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_item' ), + 'permission_callback' => array( $this, 'delete_item_permissions_check' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the guidelines.', 'gutenberg' ), + 'type' => 'integer', + ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + // Revisions route. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)/revisions', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_revisions' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the guidelines.', 'gutenberg' ), + 'type' => 'integer', + ), + ), + ), + ) + ); + } + + /** + * Checks if a given request has access to read guidelines. + * + * @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 get_item_permissions_check( $request ) { + if ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'Sorry, you are not allowed to view content guidelines.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Checks if a given request has access to create guidelines. + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has create access, WP_Error object otherwise. + */ + public function create_item_permissions_check( $request ) { + if ( ! current_user_can( 'manage_options' ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'Sorry, you are not allowed to create content guidelines.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Checks if a given request has access to update guidelines. + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has update access, WP_Error object otherwise. + */ + public function update_item_permissions_check( $request ) { + return $this->create_item_permissions_check( $request ); + } + + /** + * Checks if a given request has access to delete guidelines. + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has delete access, WP_Error object otherwise. + */ + public function delete_item_permissions_check( $request ) { + return $this->create_item_permissions_check( $request ); + } + + /** + * Gets the content guidelines. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure. + */ + public function get_item( $request ) { + $post = $this->get_guidelines_post(); + + if ( ! $post ) { + return rest_ensure_response( + array( + 'id' => 0, + 'status' => 'draft', + 'guideline_categories' => new stdClass(), + ) + ); + } + + return $this->prepare_item_for_response( $post, $request ); + } + + /** + * Gets the content guidelines by ID. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure. + */ + public function get_item_by_id( $request ) { + $post = get_post( $request['id'] ); + + if ( ! $post || $this->post_type !== $post->post_type ) { + return new WP_Error( + 'rest_post_not_found', + __( 'Content guidelines not found.', 'gutenberg' ), + array( 'status' => 404 ) + ); + } + + return $this->prepare_item_for_response( $post, $request ); + } + + /** + * Creates content guidelines. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure. + */ + public function create_item( $request ) { + // Check if guidelines already exist. + $existing = $this->get_guidelines_post(); + if ( $existing ) { + return new WP_Error( + 'rest_guidelines_exists', + __( 'Content guidelines already exist. Use PATCH to update.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + $prepared = $this->prepare_item_for_database( $request ); + + $post_id = wp_insert_post( + array( + 'post_type' => $this->post_type, + 'post_status' => isset( $prepared['status'] ) && 'published' === $prepared['status'] ? 'publish' : 'draft', + 'post_title' => __( 'Content Guidelines', 'gutenberg' ), + 'post_content' => wp_json_encode( $prepared['content'] ), + ), + true + ); + + if ( is_wp_error( $post_id ) ) { + return $post_id; + } + + $post = get_post( $post_id ); + return $this->prepare_item_for_response( $post, $request ); + } + + /** + * Updates content guidelines. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure. + */ + public function update_item( $request ) { + $post = get_post( $request['id'] ); + + if ( ! $post || $this->post_type !== $post->post_type ) { + return new WP_Error( + 'rest_post_not_found', + __( 'Content guidelines not found.', 'gutenberg' ), + array( 'status' => 404 ) + ); + } + + $prepared = $this->prepare_item_for_database( $request ); + + // Get existing content and merge with new content. + $existing_content = json_decode( $post->post_content, true ); + if ( ! is_array( $existing_content ) ) { + $existing_content = array(); + } + + $new_content = array_merge( $existing_content, $prepared['content'] ); + + $update_args = array( + 'ID' => $post->ID, + 'post_content' => wp_json_encode( $new_content ), + ); + + if ( isset( $prepared['status'] ) ) { + $update_args['post_status'] = 'published' === $prepared['status'] ? 'publish' : 'draft'; + } + + $result = wp_update_post( $update_args, true ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $post = get_post( $post->ID ); + return $this->prepare_item_for_response( $post, $request ); + } + + /** + * Deletes content guidelines. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure. + */ + public function delete_item( $request ) { + $post = get_post( $request['id'] ); + + if ( ! $post || $this->post_type !== $post->post_type ) { + return new WP_Error( + 'rest_post_not_found', + __( 'Content guidelines not found.', 'gutenberg' ), + array( 'status' => 404 ) + ); + } + + $result = wp_delete_post( $post->ID, true ); + + if ( ! $result ) { + return new WP_Error( + 'rest_cannot_delete', + __( 'The content guidelines cannot be deleted.', 'gutenberg' ), + array( 'status' => 500 ) + ); + } + + return rest_ensure_response( + array( + 'deleted' => true, + 'id' => $post->ID, + ) + ); + } + + /** + * Gets revisions for the content guidelines. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure. + */ + public function get_revisions( $request ) { + $post = get_post( $request['id'] ); + + if ( ! $post || $this->post_type !== $post->post_type ) { + return new WP_Error( + 'rest_post_not_found', + __( 'Content guidelines not found.', 'gutenberg' ), + array( 'status' => 404 ) + ); + } + + $revisions = wp_get_post_revisions( + $post->ID, + array( + 'posts_per_page' => 10, + 'orderby' => 'date', + 'order' => 'DESC', + ) + ); + + $data = array(); + foreach ( $revisions as $revision ) { + $author = get_user_by( 'id', $revision->post_author ); + $data[] = array( + 'id' => $revision->ID, + 'date' => mysql_to_rfc3339( $revision->post_date ), + 'date_gmt' => mysql_to_rfc3339( $revision->post_date_gmt ), + 'author' => $revision->post_author, + 'author_name' => $author ? $author->display_name : __( 'Unknown', 'gutenberg' ), + ); + } + + return rest_ensure_response( $data ); + } + + /** + * Prepares a single guidelines output for response. + * + * @param WP_Post $post Post object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $post, $request ) { + $content = json_decode( $post->post_content, true ); + if ( ! is_array( $content ) ) { + $content = array(); + } + + $author = get_user_by( 'id', $post->post_author ); + + $data = array( + 'id' => $post->ID, + 'status' => 'publish' === $post->post_status ? 'published' : 'draft', + 'guideline_categories' => ! empty( $content['guideline_categories'] ) ? $content['guideline_categories'] : new stdClass(), + 'date' => mysql_to_rfc3339( $post->post_date ), + 'date_gmt' => mysql_to_rfc3339( $post->post_date_gmt ), + 'modified' => mysql_to_rfc3339( $post->post_modified ), + 'modified_gmt' => mysql_to_rfc3339( $post->post_modified_gmt ), + 'author' => $post->post_author, + 'author_name' => $author ? $author->display_name : __( 'Unknown', 'gutenberg' ), + ); + + return rest_ensure_response( $data ); + } + + /** + * Prepares a single guidelines for database. + * + * @param WP_REST_Request $request Request object. + * @return array Prepared data. + */ + protected function prepare_item_for_database( $request ) { + $prepared = array( + 'content' => array(), + ); + + if ( isset( $request['status'] ) ) { + $prepared['status'] = $request['status']; + } + + if ( isset( $request['guideline_categories'] ) ) { + $prepared['content']['guideline_categories'] = $request['guideline_categories']; + } + + return $prepared; + } + + /** + * Gets the single guidelines post. + * + * @return WP_Post|null The guidelines post or null if not found. + */ + protected function get_guidelines_post() { + $posts = get_posts( + array( + 'post_type' => $this->post_type, + 'post_status' => array( 'publish', 'draft' ), + 'posts_per_page' => 1, + 'orderby' => 'date', + 'order' => 'DESC', + ) + ); + + return ! empty( $posts ) ? $posts[0] : null; + } + + /** + * Retrieves the guidelines schema, conforming to JSON Schema. + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $this->schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'content-guidelines', + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the guidelines.', 'gutenberg' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'status' => array( + 'description' => __( 'The status of the guidelines (draft or published).', 'gutenberg' ), + 'type' => 'string', + 'enum' => array( 'draft', 'published' ), + 'context' => array( 'view', 'edit' ), + ), + 'guideline_categories' => array( + 'description' => __( 'The guideline categories and their content.', 'gutenberg' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'properties' => array( + 'copy' => array( + 'type' => 'object', + 'properties' => array( + 'label' => array( 'type' => 'string' ), + 'guidelines' => array( 'type' => 'string' ), + ), + ), + 'images' => array( + 'type' => 'object', + 'properties' => array( + 'label' => array( 'type' => 'string' ), + 'guidelines' => array( 'type' => 'string' ), + ), + ), + 'site' => array( + 'type' => 'object', + 'properties' => array( + 'label' => array( 'type' => 'string' ), + 'guidelines' => array( 'type' => 'string' ), + ), + ), + 'blocks' => array( + 'type' => 'object', + 'additionalProperties' => array( + 'type' => 'object', + 'properties' => array( + 'guidelines' => array( 'type' => 'string' ), + ), + ), + ), + 'other' => array( + 'type' => 'object', + 'properties' => array( + 'label' => array( 'type' => 'string' ), + 'guidelines' => array( 'type' => 'string' ), + ), + ), + ), + ), + 'date' => array( + 'description' => __( 'The date the guidelines were created, in the site\'s timezone.', 'gutenberg' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'date_gmt' => array( + 'description' => __( 'The date the guidelines were created, as GMT.', 'gutenberg' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'modified' => array( + 'description' => __( 'The date the guidelines were last modified, in the site\'s timezone.', 'gutenberg' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'modified_gmt' => array( + 'description' => __( 'The date the guidelines were last modified, as GMT.', 'gutenberg' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'author' => array( + 'description' => __( 'The ID of the author of the guidelines.', 'gutenberg' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'author_name' => array( + 'description' => __( 'The display name of the author.', 'gutenberg' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + ), + ); + + return $this->add_additional_fields_schema( $this->schema ); + } +} diff --git a/lib/experimental/content-guidelines/index.php b/lib/experimental/content-guidelines/index.php new file mode 100644 index 00000000000000..496ec9ac610fbb --- /dev/null +++ b/lib/experimental/content-guidelines/index.php @@ -0,0 +1,30 @@ +register_routes(); + } +); + +// Register admin page. +add_action( 'admin_menu', array( 'Gutenberg_Content_Guidelines_Admin_Page', 'register_menu' ) ); +add_action( 'admin_enqueue_scripts', array( 'Gutenberg_Content_Guidelines_Admin_Page', 'enqueue_scripts' ) ); diff --git a/lib/experiments-page.php b/lib/experiments-page.php index bbf55f6ee1ca74..2b365456bef347 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -245,6 +245,18 @@ function gutenberg_initialize_experiments_settings() { ) ); + add_settings_field( + 'gutenberg-content-guidelines', + __( 'Content Guidelines', 'gutenberg' ), + 'gutenberg_display_experiment_field', + 'gutenberg-experiments', + 'gutenberg_experiments_section', + array( + 'label' => __( 'Enables Content Guidelines feature for managing editorial voice and tone guidelines under Settings.', 'gutenberg' ), + 'id' => 'gutenberg-content-guidelines', + ) + ); + register_setting( 'gutenberg-experiments', 'gutenberg-experiments' diff --git a/lib/load.php b/lib/load.php index e7357849b65990..a1e53b8b65b891 100644 --- a/lib/load.php +++ b/lib/load.php @@ -197,3 +197,8 @@ function gutenberg_is_experiment_enabled( $name ) { if ( gutenberg_is_experiment_enabled( 'gutenberg-customizable-navigation-overlays' ) ) { require __DIR__ . '/experimental/overlay-patterns.php'; } + +// Content Guidelines (only load when experiment is enabled). +if ( gutenberg_is_experiment_enabled( 'gutenberg-content-guidelines' ) ) { + require __DIR__ . '/experimental/content-guidelines/index.php'; +} diff --git a/package-lock.json b/package-lock.json index 4926143f58e50b..8ac29a42a4e846 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17469,6 +17469,10 @@ "resolved": "packages/compose", "link": true }, + "node_modules/@wordpress/content-guidelines": { + "resolved": "packages/content-guidelines", + "link": true + }, "node_modules/@wordpress/core-abilities": { "resolved": "packages/core-abilities", "link": true @@ -52706,6 +52710,31 @@ "tiny-emitter": "^2.0.0" } }, + "packages/content-guidelines": { + "name": "@wordpress/content-guidelines", + "version": "0.1.0", + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/api-fetch": "file:../api-fetch", + "@wordpress/blocks": "file:../blocks", + "@wordpress/components": "file:../components", + "@wordpress/data": "file:../data", + "@wordpress/date": "file:../date", + "@wordpress/dom-ready": "file:../dom-ready", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/icons": "file:../icons", + "@wordpress/notices": "file:../notices" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "packages/core-abilities": { "name": "@wordpress/core-abilities", "version": "0.3.0", @@ -55170,16 +55199,6 @@ "npm": ">=8.19.2" } }, - "packages/react-native-editor/node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "packages/react-native-editor/node_modules/@babel/plugin-transform-arrow-functions": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", diff --git a/packages/content-guidelines/package.json b/packages/content-guidelines/package.json new file mode 100644 index 00000000000000..a81431cbee3be9 --- /dev/null +++ b/packages/content-guidelines/package.json @@ -0,0 +1,59 @@ +{ + "name": "@wordpress/content-guidelines", + "version": "0.1.0", + "description": "Content Guidelines management for WordPress.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "content-guidelines" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/content-guidelines/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/content-guidelines" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "main": "build/index.cjs", + "module": "build-module/index.mjs", + "exports": { + ".": { + "import": "./build-module/index.mjs", + "require": "./build/index.cjs" + }, + "./package.json": "./package.json" + }, + "react-native": "src/index", + "wpScript": true, + "sideEffects": [ + "build-style/**", + "src/**/*.scss" + ], + "dependencies": { + "@wordpress/api-fetch": "file:../api-fetch", + "@wordpress/blocks": "file:../blocks", + "@wordpress/components": "file:../components", + "@wordpress/data": "file:../data", + "@wordpress/date": "file:../date", + "@wordpress/dom-ready": "file:../dom-ready", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/icons": "file:../icons", + "@wordpress/notices": "file:../notices" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/content-guidelines/src/components/admin-page/block-guidelines-panel.tsx b/packages/content-guidelines/src/components/admin-page/block-guidelines-panel.tsx new file mode 100644 index 00000000000000..3f0a64bf053955 --- /dev/null +++ b/packages/content-guidelines/src/components/admin-page/block-guidelines-panel.tsx @@ -0,0 +1,213 @@ +/** + * WordPress dependencies + */ +import { useState, useEffect } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { + PanelBody, + SelectControl, + TextareaControl, + Button, + Spinner, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { trash } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import type { BlockGuidelines } from '../../store/constants'; + +interface BlockTypeResponse { + name: string; + title: string | { rendered: string } | null; +} + +interface BlockType { + name: string; + title: string; +} + +interface BlockGuidelinesPanelProps { + value: BlockGuidelines; + onChange: ( value: BlockGuidelines ) => void; +} + +/** + * Block guidelines panel component for managing per-block guidelines. + * + * @param props Component props. + * @param props.value Current block guidelines object. + * @param props.onChange Callback when block guidelines change. + * @return BlockGuidelinesPanel component. + */ +export default function BlockGuidelinesPanel( { + value, + onChange, +}: BlockGuidelinesPanelProps ) { + const [ selectedBlock, setSelectedBlock ] = useState( '' ); + const [ blockTypes, setBlockTypes ] = useState< BlockType[] >( [] ); + const [ isLoading, setIsLoading ] = useState( true ); + + // Fetch block types from REST API. + useEffect( () => { + const fetchBlockTypes = async () => { + try { + const response = await apiFetch< BlockTypeResponse[] >( { + path: '/wp/v2/block-types', + } ); + // Process response to normalize title and filter out invalid entries. + const processedBlocks = response + .map( ( block ) => { + let title = ''; + if ( typeof block.title === 'string' ) { + title = block.title; + } else if ( + block.title && + typeof block.title === 'object' && + 'rendered' in block.title + ) { + title = block.title.rendered; + } + return { + name: block.name, + title: title || block.name, // Fallback to block name. + }; + } ) + .filter( ( block ) => block.name && block.title ); + setBlockTypes( processedBlocks ); + } catch ( err ) { + // eslint-disable-next-line no-console + console.error( 'Failed to fetch block types:', err ); + } finally { + setIsLoading( false ); + } + }; + + fetchBlockTypes(); + }, [] ); + + // Sort blocks alphabetically by title. + const sortedBlockTypes = [ ...blockTypes ].sort( ( a, b ) => + ( a.title || '' ).localeCompare( b.title || '' ) + ); + + const blockOptions = sortedBlockTypes.map( ( block ) => ( { + label: block.title, + value: block.name, + } ) ); + + const handleBlockGuidelineChange = ( + blockName: string, + guidelines: string + ) => { + onChange( { + ...value, + [ blockName ]: { guidelines }, + } ); + }; + + const handleRemoveBlockGuideline = ( blockName: string ) => { + const newValue = { ...value }; + delete newValue[ blockName ]; + onChange( newValue ); + + if ( selectedBlock === blockName ) { + setSelectedBlock( '' ); + } + }; + + const getBlockTitle = ( blockName: string ): string => { + const block = blockTypes.find( ( b ) => b.name === blockName ); + return block?.title || blockName; + }; + + const configuredBlocks = Object.entries( value ).filter( + ( [ , data ] ) => data.guidelines && data.guidelines.trim() !== '' + ); + + return ( + + { isLoading ? ( +
+ + { __( 'Loading block types…' ) } +
+ ) : ( + + ) } + + { selectedBlock && ( +
+ + handleBlockGuidelineChange( selectedBlock, text ) + } + rows={ 4 } + help={ __( + 'Enter guidelines specific to this block type.' + ) } + /> +
+ ) } + + { configuredBlocks.length > 0 && ( +
+

+ { __( 'Configured Blocks' ) } +

+
    + { configuredBlocks.map( ( [ blockName, data ] ) => ( +
  • +
    + + { getBlockTitle( blockName ) } + +

    + { data.guidelines.length > 100 + ? `${ data.guidelines.substring( + 0, + 100 + ) }...` + : data.guidelines } +

    +
    +
  • + ) ) } +
+
+ ) } +
+ ); +} diff --git a/packages/content-guidelines/src/components/admin-page/category-panel.tsx b/packages/content-guidelines/src/components/admin-page/category-panel.tsx new file mode 100644 index 00000000000000..ab22892de251af --- /dev/null +++ b/packages/content-guidelines/src/components/admin-page/category-panel.tsx @@ -0,0 +1,41 @@ +/** + * WordPress dependencies + */ +import { PanelBody, TextareaControl } from '@wordpress/components'; + +interface CategoryPanelProps { + label: string; + description: string; + value: string; + onChange: ( value: string ) => void; +} + +/** + * Category panel component for displaying a single guideline category. + * + * @param props Component props. + * @param props.label Panel label/title. + * @param props.description Help text for the textarea. + * @param props.value Current value of the textarea. + * @param props.onChange Callback when value changes. + * @return CategoryPanel component. + */ +export default function CategoryPanel( { + label, + description, + value, + onChange, +}: CategoryPanelProps ) { + return ( + + + + ); +} diff --git a/packages/content-guidelines/src/components/admin-page/index.tsx b/packages/content-guidelines/src/components/admin-page/index.tsx new file mode 100644 index 00000000000000..6215fc1dbeb287 --- /dev/null +++ b/packages/content-guidelines/src/components/admin-page/index.tsx @@ -0,0 +1,190 @@ +/** + * WordPress dependencies + */ +import { useEffect } from '@wordpress/element'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { Panel, Spinner, Notice } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import { store } from '../../store'; +import type { + BlockGuidelines, + GuidelineCategories, +} from '../../store/constants'; +import CategoryPanel from './category-panel'; +import BlockGuidelinesPanel from './block-guidelines-panel'; +import PublishControls from './publish-controls'; +import RevisionList from './revision-list'; + +interface Category { + slug: keyof GuidelineCategories; + label: string; + description: string; +} + +const CATEGORIES: Category[] = [ + { + slug: 'copy', + label: __( 'Copy Guidelines' ), + description: __( + 'Describe your tone, voice, brand personality, and writing style preferences.' + ), + }, + { + slug: 'images', + label: __( 'Image Guidelines' ), + description: __( + 'Describe preferred image styles, colors, compositions, and visual aesthetics.' + ), + }, + { + slug: 'site', + label: __( 'Site Context' ), + description: __( + 'Describe your site goals, target audience, and overall content strategy.' + ), + }, + { + slug: 'blocks', + label: __( 'Block-Specific Guidelines' ), + description: __( 'Per-block rules for specific content types.' ), + }, + { + slug: 'other', + label: __( 'Other Guidelines' ), + description: __( + 'Any other AI content preferences or special instructions.' + ), + }, +]; + +export default function AdminPage() { + const { guidelines, isLoadingData, isSavingData, isDirtyData, error } = + useSelect( ( select ) => { + const storeSelectors = select( store ); + return { + guidelines: storeSelectors.getGuidelines(), + isLoadingData: storeSelectors.isLoading(), + isSavingData: storeSelectors.isSaving(), + isDirtyData: storeSelectors.isDirty(), + error: storeSelectors.getError(), + }; + }, [] ); + + const { fetchGuidelines, saveGuidelines, updateCategory, setStatus } = + useDispatch( store ); + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + + useEffect( () => { + fetchGuidelines(); + }, [ fetchGuidelines ] ); + + const handleSave = async () => { + if ( ! guidelines ) { + return; + } + try { + await saveGuidelines( guidelines ); + createSuccessNotice( __( 'Guidelines saved successfully.' ), { + type: 'snackbar', + } ); + } catch ( err ) { + createErrorNotice( + ( err as Error ).message || __( 'Failed to save guidelines.' ), + { type: 'snackbar' } + ); + } + }; + + const handleStatusChange = ( newStatus: 'draft' | 'published' ) => { + setStatus( newStatus ); + }; + + const handleCategoryChange = ( + category: string, + value: string | BlockGuidelines + ) => { + if ( category === 'blocks' ) { + updateCategory( category, value as BlockGuidelines ); + } else { + updateCategory( category, { guidelines: value as string } ); + } + }; + + if ( isLoadingData ) { + return ( +
+ +

{ __( 'Loading guidelines…' ) }

+
+ ); + } + + return ( +
+
+
+

{ __( 'Content Guidelines' ) }

+

+ { __( + 'Define editorial guidelines that inform AI-generated content across your site.' + ) } +

+
+
+ + { error && ( + + { error } + + ) } + + + + + { CATEGORIES.map( ( category ) => + category.slug === 'blocks' ? ( + + handleCategoryChange( 'blocks', value ) + } + /> + ) : ( + + handleCategoryChange( category.slug, value ) + } + /> + ) + ) } + + + +
+ ); +} diff --git a/packages/content-guidelines/src/components/admin-page/publish-controls.tsx b/packages/content-guidelines/src/components/admin-page/publish-controls.tsx new file mode 100644 index 00000000000000..da347b2d4004a9 --- /dev/null +++ b/packages/content-guidelines/src/components/admin-page/publish-controls.tsx @@ -0,0 +1,65 @@ +/** + * WordPress dependencies + */ +import { Button, ToggleControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +interface PublishControlsProps { + status: 'draft' | 'published'; + isDirty: boolean; + isSaving: boolean; + onSave: () => void; + onStatusChange: ( status: 'draft' | 'published' ) => void; +} + +/** + * Publish controls component for managing draft/published state. + * + * @param props Component props. + * @param props.status Current status ('draft' or 'published'). + * @param props.isDirty Whether there are unsaved changes. + * @param props.isSaving Whether save is in progress. + * @param props.onSave Callback when save button is clicked. + * @param props.onStatusChange Callback when status toggle changes. + * @return PublishControls component. + */ +export default function PublishControls( { + status, + isDirty, + isSaving, + onSave, + onStatusChange, +}: PublishControlsProps ) { + const isPublished = status === 'published'; + + return ( +
+ + onStatusChange( checked ? 'published' : 'draft' ) + } + help={ + isPublished + ? __( + 'Guidelines are active and available to AI tools.' + ) + : __( 'Guidelines are saved as draft.' ) + } + /> + +
+ ); +} diff --git a/packages/content-guidelines/src/components/admin-page/revision-list.tsx b/packages/content-guidelines/src/components/admin-page/revision-list.tsx new file mode 100644 index 00000000000000..4ae3a2b544602d --- /dev/null +++ b/packages/content-guidelines/src/components/admin-page/revision-list.tsx @@ -0,0 +1,116 @@ +/** + * WordPress dependencies + */ +import { useState, useEffect } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { dateI18n, getSettings } from '@wordpress/date'; +import { Spinner } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +interface Revision { + id: number; + date: string; + author_name: string; +} + +interface RevisionListProps { + postId: number | undefined; +} + +/** + * Revision list component for displaying revision history. + * + * @param props Component props. + * @param props.postId The guidelines post ID. + * @return RevisionList component. + */ +export default function RevisionList( { postId }: RevisionListProps ) { + const [ revisions, setRevisions ] = useState< Revision[] >( [] ); + const [ isLoading, setIsLoading ] = useState( false ); + const [ error, setError ] = useState< string | null >( null ); + + useEffect( () => { + if ( ! postId ) { + setRevisions( [] ); + return; + } + + const fetchRevisions = async () => { + setIsLoading( true ); + setError( null ); + + try { + const response = await apiFetch< Revision[] >( { + path: `/__experimental/content-guidelines/${ postId }/revisions`, + } ); + setRevisions( response ); + } catch ( err ) { + setError( + ( err as Error ).message || + __( 'Failed to load revisions.' ) + ); + } finally { + setIsLoading( false ); + } + }; + + fetchRevisions(); + }, [ postId ] ); + + if ( ! postId ) { + return null; + } + + if ( isLoading ) { + return ( +
+ +

{ __( 'Loading revisions…' ) }

+
+ ); + } + + if ( error ) { + return ( +
+

{ error }

+
+ ); + } + + if ( ! revisions.length ) { + return ( +
+

{ __( 'Revision History' ) }

+

+ { __( 'No revisions yet.' ) } +

+
+ ); + } + + const dateFormat = getSettings().formats.datetime; + + return ( +
+

{ __( 'Revision History' ) }

+
    + { revisions.map( ( revision, index ) => ( +
  • + + { index === 0 + ? __( 'Current' ) + : `#${ revisions.length - index }` } + + + { dateI18n( dateFormat, revision.date ) } + + + { revision.author_name || __( 'Unknown' ) } + +
  • + ) ) } +
+
+ ); +} diff --git a/packages/content-guidelines/src/index.tsx b/packages/content-guidelines/src/index.tsx new file mode 100644 index 00000000000000..0a3a1fbfd53e5f --- /dev/null +++ b/packages/content-guidelines/src/index.tsx @@ -0,0 +1,23 @@ +/** + * WordPress dependencies + */ +import domReady from '@wordpress/dom-ready'; +import { createRoot } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import AdminPage from './components/admin-page'; +import './style.scss'; + +// Render admin page when DOM is ready. +domReady( () => { + const container = document.getElementById( 'content-guidelines-admin' ); + if ( container ) { + const root = createRoot( container ); + root.render( ); + } +} ); + +// Export store for external use. +export { store } from './store'; diff --git a/packages/content-guidelines/src/store/actions.ts b/packages/content-guidelines/src/store/actions.ts new file mode 100644 index 00000000000000..3cdf12eb182e93 --- /dev/null +++ b/packages/content-guidelines/src/store/actions.ts @@ -0,0 +1,155 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import type { + Guidelines, + CategoryGuideline, + BlockGuidelines, +} from './constants'; + +/** + * Action Types + */ +export const FETCH_GUIDELINES_START = 'FETCH_GUIDELINES_START' as const; +export const FETCH_GUIDELINES_SUCCESS = 'FETCH_GUIDELINES_SUCCESS' as const; +export const FETCH_GUIDELINES_ERROR = 'FETCH_GUIDELINES_ERROR' as const; +export const SAVE_GUIDELINES_START = 'SAVE_GUIDELINES_START' as const; +export const SAVE_GUIDELINES_SUCCESS = 'SAVE_GUIDELINES_SUCCESS' as const; +export const SAVE_GUIDELINES_ERROR = 'SAVE_GUIDELINES_ERROR' as const; +export const UPDATE_CATEGORY = 'UPDATE_CATEGORY' as const; +export const SET_STATUS = 'SET_STATUS' as const; +export const RESET_CHANGES = 'RESET_CHANGES' as const; + +/** + * Action type definitions + */ +export type Action = + | { type: typeof FETCH_GUIDELINES_START } + | { type: typeof FETCH_GUIDELINES_SUCCESS; payload: Guidelines } + | { type: typeof FETCH_GUIDELINES_ERROR; payload: string } + | { type: typeof SAVE_GUIDELINES_START } + | { type: typeof SAVE_GUIDELINES_SUCCESS; payload: Guidelines } + | { type: typeof SAVE_GUIDELINES_ERROR; payload: string } + | { + type: typeof UPDATE_CATEGORY; + payload: { + category: string; + value: CategoryGuideline | BlockGuidelines; + }; + } + | { type: typeof SET_STATUS; payload: 'draft' | 'published' } + | { type: typeof RESET_CHANGES }; + +export interface ThunkArgs { + dispatch: ( action: Action ) => void; +} + +/** + * Fetch guidelines from the REST API. + * + * @return Thunk action. + */ +export const fetchGuidelines = + () => + async ( { dispatch }: ThunkArgs ) => { + dispatch( { type: FETCH_GUIDELINES_START } ); + + try { + const response = await apiFetch< Guidelines >( { + path: '/__experimental/content-guidelines', + } ); + + dispatch( { + type: FETCH_GUIDELINES_SUCCESS, + payload: response, + } ); + } catch ( error ) { + dispatch( { + type: FETCH_GUIDELINES_ERROR, + payload: + ( error as Error ).message || 'Failed to fetch guidelines', + } ); + } + }; + +/** + * Save guidelines to the REST API. + * + * @param data Guidelines data to save. + * @return Thunk action. + */ +export const saveGuidelines = + ( data: Guidelines ) => + async ( { dispatch }: ThunkArgs ) => { + dispatch( { type: SAVE_GUIDELINES_START } ); + + try { + const method = data.id ? 'PATCH' : 'POST'; + const path = data.id + ? `/__experimental/content-guidelines/${ data.id }` + : '/__experimental/content-guidelines'; + + const response = await apiFetch< Guidelines >( { + path, + method, + data: { + status: data.status, + guideline_categories: data.guideline_categories, + }, + } ); + + dispatch( { + type: SAVE_GUIDELINES_SUCCESS, + payload: response, + } ); + + return response; + } catch ( error ) { + dispatch( { + type: SAVE_GUIDELINES_ERROR, + payload: + ( error as Error ).message || 'Failed to save guidelines', + } ); + throw error; + } + }; + +/** + * Update a specific category's content. + * + * @param category The category slug. + * @param value The new value for the category. + * @return Action object. + */ +export const updateCategory = ( + category: string, + value: CategoryGuideline | BlockGuidelines +): Action => ( { + type: UPDATE_CATEGORY, + payload: { category, value }, +} ); + +/** + * Set the status of the guidelines. + * + * @param status The status ('draft' or 'published'). + * @return Action object. + */ +export const setStatus = ( status: 'draft' | 'published' ): Action => ( { + type: SET_STATUS, + payload: status, +} ); + +/** + * Reset changes to the original state. + * + * @return Action object. + */ +export const resetChanges = (): Action => ( { + type: RESET_CHANGES, +} ); diff --git a/packages/content-guidelines/src/store/constants.ts b/packages/content-guidelines/src/store/constants.ts new file mode 100644 index 00000000000000..141f6cb6f3386e --- /dev/null +++ b/packages/content-guidelines/src/store/constants.ts @@ -0,0 +1,80 @@ +/** + * Store name. + */ +export const STORE_NAME = 'content-guidelines'; + +/** + * Types for the store. + */ +export interface CategoryGuideline { + label?: string; + guidelines: string; +} + +export interface BlockGuidelines { + [ blockName: string ]: { + guidelines: string; + }; +} + +export interface GuidelineCategories { + copy: CategoryGuideline; + images: CategoryGuideline; + site: CategoryGuideline; + blocks: BlockGuidelines; + other: CategoryGuideline; +} + +export interface Guidelines { + id: number; + status: 'draft' | 'published'; + guideline_categories: GuidelineCategories; + date?: string; + date_gmt?: string; + modified?: string; + modified_gmt?: string; + author?: number; + author_name?: string; +} + +export interface State { + guidelines: Guidelines | null; + originalGuidelines: Guidelines | null; + isLoading: boolean; + isSaving: boolean; + error: string | null; +} + +/** + * Default guideline categories. + */ +export const DEFAULT_CATEGORIES: GuidelineCategories = { + copy: { + label: 'Copy Guidelines', + guidelines: '', + }, + images: { + label: 'Image Guidelines', + guidelines: '', + }, + site: { + label: 'Site Context', + guidelines: '', + }, + blocks: {}, + other: { + label: 'Other Guidelines', + guidelines: '', + }, +}; + +/** + * Default state. + */ +export const DEFAULT_STATE: State = { + guidelines: null, + originalGuidelines: null, + isLoading: false, + isSaving: false, + error: null, +}; diff --git a/packages/content-guidelines/src/store/index.ts b/packages/content-guidelines/src/store/index.ts new file mode 100644 index 00000000000000..fd31a8c57974be --- /dev/null +++ b/packages/content-guidelines/src/store/index.ts @@ -0,0 +1,40 @@ +/** + * WordPress dependencies + */ +import { createReduxStore, register } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import { + fetchGuidelines, + saveGuidelines, + updateCategory, + setStatus, + resetChanges, +} from './actions'; +import * as selectors from './selectors'; +import { STORE_NAME } from './constants'; + +/** + * Action creators for the store (excluding action type constants). + */ +const actions = { + fetchGuidelines, + saveGuidelines, + updateCategory, + setStatus, + resetChanges, +}; + +/** + * Store definition for the content guidelines namespace. + */ +export const store = createReduxStore( STORE_NAME, { + reducer, + actions, + selectors, +} ); + +register( store ); diff --git a/packages/content-guidelines/src/store/reducer.ts b/packages/content-guidelines/src/store/reducer.ts new file mode 100644 index 00000000000000..790f8fd0908a5b --- /dev/null +++ b/packages/content-guidelines/src/store/reducer.ts @@ -0,0 +1,131 @@ +/** + * Internal dependencies + */ +import { + FETCH_GUIDELINES_START, + FETCH_GUIDELINES_SUCCESS, + FETCH_GUIDELINES_ERROR, + SAVE_GUIDELINES_START, + SAVE_GUIDELINES_SUCCESS, + SAVE_GUIDELINES_ERROR, + UPDATE_CATEGORY, + SET_STATUS, + RESET_CHANGES, +} from './actions'; +import type { Action } from './actions'; +import { DEFAULT_STATE, DEFAULT_CATEGORIES } from './constants'; +import type { State, Guidelines } from './constants'; + +/** + * Reducer for the content guidelines store. + * + * @param state Current state. + * @param action Action object. + * @return Updated state. + */ +export default function reducer( + state: State = DEFAULT_STATE, + action: Action +): State { + switch ( action.type ) { + case FETCH_GUIDELINES_START: + return { + ...state, + isLoading: true, + error: null, + }; + + case FETCH_GUIDELINES_SUCCESS: { + const guidelines: Guidelines = { + ...action.payload, + guideline_categories: { + ...DEFAULT_CATEGORIES, + ...( action.payload.guideline_categories || {} ), + }, + }; + return { + ...state, + guidelines, + originalGuidelines: JSON.parse( JSON.stringify( guidelines ) ), + isLoading: false, + error: null, + }; + } + + case FETCH_GUIDELINES_ERROR: + return { + ...state, + isLoading: false, + error: action.payload, + }; + + case SAVE_GUIDELINES_START: + return { + ...state, + isSaving: true, + error: null, + }; + + case SAVE_GUIDELINES_SUCCESS: { + const guidelines: Guidelines = { + ...action.payload, + guideline_categories: { + ...DEFAULT_CATEGORIES, + ...( action.payload.guideline_categories || {} ), + }, + }; + return { + ...state, + guidelines, + originalGuidelines: JSON.parse( JSON.stringify( guidelines ) ), + isSaving: false, + error: null, + }; + } + + case SAVE_GUIDELINES_ERROR: + return { + ...state, + isSaving: false, + error: action.payload, + }; + + case UPDATE_CATEGORY: { + const { category, value } = action.payload; + return { + ...state, + guidelines: state.guidelines + ? { + ...state.guidelines, + guideline_categories: { + ...state.guidelines.guideline_categories, + [ category ]: value, + }, + } + : null, + }; + } + + case SET_STATUS: + return { + ...state, + guidelines: state.guidelines + ? { + ...state.guidelines, + status: action.payload, + } + : null, + }; + + case RESET_CHANGES: + return { + ...state, + guidelines: state.originalGuidelines + ? JSON.parse( JSON.stringify( state.originalGuidelines ) ) + : null, + }; + + default: + return state; + } +} diff --git a/packages/content-guidelines/src/store/selectors.ts b/packages/content-guidelines/src/store/selectors.ts new file mode 100644 index 00000000000000..90d23d762b0006 --- /dev/null +++ b/packages/content-guidelines/src/store/selectors.ts @@ -0,0 +1,88 @@ +/** + * Internal dependencies + */ +import type { + State, + Guidelines, + CategoryGuideline, + BlockGuidelines, +} from './constants'; + +/** + * Get the full guidelines object. + * + * @param state Store state. + * @return Guidelines object or null. + */ +export const getGuidelines = ( state: State ): Guidelines | null => + state.guidelines; + +/** + * Get a specific category from the guidelines. + * + * @param state Store state. + * @param category Category slug. + * @return Category data or null. + */ +export const getCategory = ( + state: State, + category: string +): CategoryGuideline | BlockGuidelines | null => { + if ( ! state.guidelines?.guideline_categories ) { + return null; + } + return ( + state.guidelines.guideline_categories[ + category as keyof typeof state.guidelines.guideline_categories + ] || null + ); +}; + +/** + * Get the current status of the guidelines. + * + * @param state Store state. + * @return Status ('draft' or 'published'). + */ +export const getStatus = ( state: State ): 'draft' | 'published' => + state.guidelines?.status || 'draft'; + +/** + * Check if there are unsaved changes. + * + * @param state Store state. + * @return True if there are unsaved changes. + */ +export const isDirty = ( state: State ): boolean => { + if ( ! state.guidelines || ! state.originalGuidelines ) { + return false; + } + return ( + JSON.stringify( state.guidelines ) !== + JSON.stringify( state.originalGuidelines ) + ); +}; + +/** + * Check if guidelines are currently being loaded. + * + * @param state Store state. + * @return True if loading. + */ +export const isLoading = ( state: State ): boolean => state.isLoading; + +/** + * Check if guidelines are currently being saved. + * + * @param state Store state. + * @return True if saving. + */ +export const isSaving = ( state: State ): boolean => state.isSaving; + +/** + * Get any error message. + * + * @param state Store state. + * @return Error message or null. + */ +export const getError = ( state: State ): string | null => state.error; diff --git a/packages/content-guidelines/src/style.scss b/packages/content-guidelines/src/style.scss new file mode 100644 index 00000000000000..20a710af896664 --- /dev/null +++ b/packages/content-guidelines/src/style.scss @@ -0,0 +1,192 @@ +.content-guidelines-admin { + max-width: 800px; + margin: 20px 20px 0 2px; + + &--loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 200px; + gap: 16px; + } + + &__header { + margin-bottom: 24px; + } + + &__title-section { + h1 { + font-size: 23px; + font-weight: 400; + margin: 0 0 8px; + padding: 9px 0 4px; + line-height: 1.3; + } + } + + &__description { + color: #646970; + font-size: 13px; + margin: 0; + } + + &__panel { + margin-top: 16px; + + .components-panel__body { + border-top: 1px solid #e0e0e0; + + &:first-child { + border-top: none; + } + } + } +} + +.content-guidelines-publish-controls { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 16px; + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 2px; + gap: 16px; + flex-wrap: wrap; + + .components-toggle-control { + margin-bottom: 0; + } + + &__save-button { + flex-shrink: 0; + } +} + +.content-guidelines-textarea { + textarea { + width: 100%; + font-family: inherit; + } +} + +.block-guidelines-editor { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #e0e0e0; +} + +.block-guidelines-list { + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid #e0e0e0; + + &__title { + font-size: 13px; + font-weight: 600; + margin: 0 0 12px; + } + + &__items { + list-style: none; + margin: 0; + padding: 0; + } +} + +.block-guideline-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 12px; + background: #f6f7f7; + border-radius: 2px; + margin-bottom: 8px; + gap: 12px; + + &:last-child { + margin-bottom: 0; + } + + &__content { + flex: 1; + min-width: 0; + } + + &__title { + display: block; + font-size: 13px; + margin-bottom: 4px; + } + + &__preview { + font-size: 12px; + color: #646970; + margin: 0; + word-break: break-word; + } +} + +.content-guidelines-revisions { + margin-top: 24px; + padding: 16px; + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 2px; + + &--loading, + &--error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100px; + gap: 8px; + } + + h3 { + font-size: 14px; + font-weight: 600; + margin: 0 0 16px; + } + + &__empty { + color: #646970; + font-size: 13px; + margin: 0; + } + + &__list { + list-style: none; + margin: 0; + padding: 0; + } +} + +.revision-item { + display: flex; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #f0f0f0; + gap: 16px; + font-size: 13px; + + &:last-child { + border-bottom: none; + } + + &__number { + font-weight: 600; + min-width: 60px; + } + + &__date { + flex: 1; + color: #646970; + } + + &__author { + color: #2271b1; + } +} diff --git a/packages/content-guidelines/tsconfig.json b/packages/content-guidelines/tsconfig.json new file mode 100644 index 00000000000000..a33e5c39e15b9b --- /dev/null +++ b/packages/content-guidelines/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.base.json", + "references": [ + { "path": "../api-fetch" }, + { "path": "../components" }, + { "path": "../data" }, + { "path": "../date" }, + { "path": "../dom-ready" }, + { "path": "../element" }, + { "path": "../i18n" }, + { "path": "../icons" }, + { "path": "../notices" } + ] +} diff --git a/tsconfig.json b/tsconfig.json index faf54630c1011b..2a1b0862e1bf58 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ { "path": "packages/block-serialization-default-parser" }, { "path": "packages/boot" }, { "path": "packages/components" }, + { "path": "packages/content-guidelines" }, { "path": "packages/compose" }, { "path": "packages/core-abilities" }, { "path": "packages/core-data" }, From dcf729c78036ac4b36b004f4283e87436dbb6ea8 Mon Sep 17 00:00:00 2001 From: Aagam Shah Date: Tue, 27 Jan 2026 10:11:14 +0530 Subject: [PATCH 02/36] Content Guidelines: Add revision support and code cleanup - Extract get_validated_post() helper in PHP REST controller to reduce duplication - Add revision state management to store (actions, selectors, reducer) - Refactor revision-list.tsx to use store instead of direct API calls - Simplify handleImport function in admin page - Add import/export controls component - Fix accessibility issues and lint warnings Co-Authored-By: Claude Opus 4.5 --- .../class-rest-controller.php | 240 +++++++++++++++++- .../admin-page/import-export-controls.tsx | 202 +++++++++++++++ .../src/components/admin-page/index.tsx | 70 +++-- .../admin-page/publish-controls.tsx | 74 +++--- .../components/admin-page/revision-list.tsx | 98 ++++--- .../content-guidelines/src/store/actions.ts | 81 +++++- .../content-guidelines/src/store/constants.ts | 12 + .../content-guidelines/src/store/index.ts | 4 + .../content-guidelines/src/store/reducer.ts | 55 ++++ .../content-guidelines/src/store/selectors.ts | 27 ++ packages/content-guidelines/src/style.scss | 47 +++- 11 files changed, 805 insertions(+), 105 deletions(-) create mode 100644 packages/content-guidelines/src/components/admin-page/import-export-controls.tsx diff --git a/lib/experimental/content-guidelines/class-rest-controller.php b/lib/experimental/content-guidelines/class-rest-controller.php index 8838b38d5c7ea0..a56aecacccb5df 100644 --- a/lib/experimental/content-guidelines/class-rest-controller.php +++ b/lib/experimental/content-guidelines/class-rest-controller.php @@ -42,6 +42,18 @@ public function register_routes() { 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'category' => array( + 'description' => __( 'Limit response to a specific guideline category.', 'gutenberg' ), + 'type' => 'string', + 'enum' => array( 'copy', 'images', 'site', 'blocks', 'other' ), + ), + 'status' => array( + 'description' => __( 'Limit response to guidelines with a specific status.', 'gutenberg' ), + 'type' => 'string', + 'enum' => array( 'draft', 'published' ), + ), + ), ), array( 'methods' => WP_REST_Server::CREATABLE, @@ -107,6 +119,52 @@ public function register_routes() { ), ) ); + + // Single revision route. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)/revisions/(?P[\d]+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_revision' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the guidelines.', 'gutenberg' ), + 'type' => 'integer', + ), + 'revision_id' => array( + 'description' => __( 'Unique identifier for the revision.', 'gutenberg' ), + 'type' => 'integer', + ), + ), + ), + ) + ); + + // Restore revision route. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)/revisions/(?P[\d]+)/restore', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'restore_revision' ), + 'permission_callback' => array( $this, 'update_item_permissions_check' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the guidelines.', 'gutenberg' ), + 'type' => 'integer', + ), + 'revision_id' => array( + 'description' => __( 'Unique identifier for the revision to restore.', 'gutenberg' ), + 'type' => 'integer', + ), + ), + ), + ) + ); } /** @@ -168,13 +226,29 @@ public function delete_item_permissions_check( $request ) { /** * Gets the content guidelines. * + * Supports query parameters: + * - ?status=published|draft - Filter by status + * - ?category=copy|images|site|blocks|other - Return only specific category + * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure. */ public function get_item( $request ) { - $post = $this->get_guidelines_post(); + $status_filter = $request->get_param( 'status' ); + $post = $this->get_guidelines_post( $status_filter ); if ( ! $post ) { + // If filtering by status and no match, return empty response. + if ( $status_filter ) { + return rest_ensure_response( + array( + 'id' => 0, + 'status' => $status_filter, + 'guideline_categories' => new stdClass(), + ) + ); + } + return rest_ensure_response( array( 'id' => 0, @@ -194,9 +268,9 @@ public function get_item( $request ) { * @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure. */ public function get_item_by_id( $request ) { - $post = get_post( $request['id'] ); + $post = $this->get_validated_post( $request['id'] ); - if ( ! $post || $this->post_type !== $post->post_type ) { + if ( ! $post ) { return new WP_Error( 'rest_post_not_found', __( 'Content guidelines not found.', 'gutenberg' ), @@ -251,9 +325,9 @@ public function create_item( $request ) { * @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure. */ public function update_item( $request ) { - $post = get_post( $request['id'] ); + $post = $this->get_validated_post( $request['id'] ); - if ( ! $post || $this->post_type !== $post->post_type ) { + if ( ! $post ) { return new WP_Error( 'rest_post_not_found', __( 'Content guidelines not found.', 'gutenberg' ), @@ -297,9 +371,9 @@ public function update_item( $request ) { * @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure. */ public function delete_item( $request ) { - $post = get_post( $request['id'] ); + $post = $this->get_validated_post( $request['id'] ); - if ( ! $post || $this->post_type !== $post->post_type ) { + if ( ! $post ) { return new WP_Error( 'rest_post_not_found', __( 'Content guidelines not found.', 'gutenberg' ), @@ -332,9 +406,9 @@ public function delete_item( $request ) { * @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure. */ public function get_revisions( $request ) { - $post = get_post( $request['id'] ); + $post = $this->get_validated_post( $request['id'] ); - if ( ! $post || $this->post_type !== $post->post_type ) { + if ( ! $post ) { return new WP_Error( 'rest_post_not_found', __( 'Content guidelines not found.', 'gutenberg' ), @@ -366,9 +440,112 @@ public function get_revisions( $request ) { return rest_ensure_response( $data ); } + /** + * Gets a single revision for the content guidelines. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure. + */ + public function get_revision( $request ) { + $post = $this->get_validated_post( $request['id'] ); + + if ( ! $post ) { + return new WP_Error( + 'rest_post_not_found', + __( 'Content guidelines not found.', 'gutenberg' ), + array( 'status' => 404 ) + ); + } + + $revision = get_post( $request['revision_id'] ); + + if ( ! $revision || 'revision' !== $revision->post_type || (int) $revision->post_parent !== (int) $post->ID ) { + return new WP_Error( + 'rest_revision_not_found', + __( 'Revision not found.', 'gutenberg' ), + array( 'status' => 404 ) + ); + } + + $content = json_decode( $revision->post_content, true ); + if ( ! is_array( $content ) ) { + $content = array(); + } + + $author = get_user_by( 'id', $revision->post_author ); + + $data = array( + 'id' => $revision->ID, + 'parent' => $revision->post_parent, + 'date' => mysql_to_rfc3339( $revision->post_date ), + 'date_gmt' => mysql_to_rfc3339( $revision->post_date_gmt ), + 'author' => $revision->post_author, + 'author_name' => $author ? $author->display_name : __( 'Unknown', 'gutenberg' ), + 'guideline_categories' => ! empty( $content['guideline_categories'] ) ? $content['guideline_categories'] : new stdClass(), + ); + + return rest_ensure_response( $data ); + } + + /** + * Restores a revision to the main guidelines post. + * + * Uses WordPress's native wp_restore_post_revision() function which: + * - Restores all revision fields (not just post_content) + * - Sets _edit_last post meta to current user + * - Fires wp_restore_post_revision action for plugin compatibility + * - Creates a new revision for audit trail + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure. + */ + public function restore_revision( $request ) { + $post = $this->get_validated_post( $request['id'] ); + + if ( ! $post ) { + return new WP_Error( + 'rest_post_not_found', + __( 'Content guidelines not found.', 'gutenberg' ), + array( 'status' => 404 ) + ); + } + + $revision = get_post( $request['revision_id'] ); + + if ( ! $revision || 'revision' !== $revision->post_type || (int) $revision->post_parent !== (int) $post->ID ) { + return new WP_Error( + 'rest_revision_not_found', + __( 'Revision not found.', 'gutenberg' ), + array( 'status' => 404 ) + ); + } + + // Use WordPress's native revision restore function. + // This restores all fields, sets _edit_last meta, and fires hooks. + $result = wp_restore_post_revision( $revision->ID ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + if ( ! $result ) { + return new WP_Error( + 'rest_cannot_restore', + __( 'Could not restore revision.', 'gutenberg' ), + array( 'status' => 500 ) + ); + } + + // Get the updated post and return the response. + $post = get_post( $post->ID ); + return $this->prepare_item_for_response( $post, $request ); + } + /** * Prepares a single guidelines output for response. * + * Supports ?category query parameter to return only a specific category. + * * @param WP_Post $post Post object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response Response object. @@ -381,10 +558,28 @@ public function prepare_item_for_response( $post, $request ) { $author = get_user_by( 'id', $post->post_author ); + $guideline_categories = ! empty( $content['guideline_categories'] ) ? $content['guideline_categories'] : array(); + + // Handle ?category filter - return only the requested category. + $category_filter = $request->get_param( 'category' ); + if ( $category_filter && ! empty( $guideline_categories ) ) { + if ( isset( $guideline_categories[ $category_filter ] ) ) { + $guideline_categories = array( + $category_filter => $guideline_categories[ $category_filter ], + ); + } else { + $guideline_categories = new stdClass(); + } + } + + if ( empty( $guideline_categories ) ) { + $guideline_categories = new stdClass(); + } + $data = array( 'id' => $post->ID, 'status' => 'publish' === $post->post_status ? 'published' : 'draft', - 'guideline_categories' => ! empty( $content['guideline_categories'] ) ? $content['guideline_categories'] : new stdClass(), + 'guideline_categories' => $guideline_categories, 'date' => mysql_to_rfc3339( $post->post_date ), 'date_gmt' => mysql_to_rfc3339( $post->post_date_gmt ), 'modified' => mysql_to_rfc3339( $post->post_modified ), @@ -418,16 +613,37 @@ protected function prepare_item_for_database( $request ) { return $prepared; } + /** + * Gets a guidelines post by ID with type validation. + * + * @param int $id Post ID. + * @return WP_Post|null Post object or null if not found/invalid. + */ + protected function get_validated_post( $id ) { + $post = get_post( $id ); + if ( ! $post || $this->post_type !== $post->post_type ) { + return null; + } + return $post; + } + /** * Gets the single guidelines post. * + * @param string|null $status_filter Optional. Filter by status ('published' or 'draft'). * @return WP_Post|null The guidelines post or null if not found. */ - protected function get_guidelines_post() { + protected function get_guidelines_post( $status_filter = null ) { + $post_status = array( 'publish', 'draft' ); + + if ( $status_filter ) { + $post_status = 'published' === $status_filter ? 'publish' : 'draft'; + } + $posts = get_posts( array( 'post_type' => $this->post_type, - 'post_status' => array( 'publish', 'draft' ), + 'post_status' => $post_status, 'posts_per_page' => 1, 'orderby' => 'date', 'order' => 'DESC', diff --git a/packages/content-guidelines/src/components/admin-page/import-export-controls.tsx b/packages/content-guidelines/src/components/admin-page/import-export-controls.tsx new file mode 100644 index 00000000000000..038d43bd32751c --- /dev/null +++ b/packages/content-guidelines/src/components/admin-page/import-export-controls.tsx @@ -0,0 +1,202 @@ +/** + * WordPress dependencies + */ +import { useState, useRef } from '@wordpress/element'; +import { useDispatch } from '@wordpress/data'; +import { + Button, + Notice, + __experimentalVStack as VStack, + __experimentalText as Text, + __experimentalSpacer as Spacer, +} from '@wordpress/components'; +import { download, upload } from '@wordpress/icons'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import type { Guidelines } from '../../store/constants'; + +interface ImportExportControlsProps { + guidelines: Guidelines | null; + onImport: ( data: Partial< Guidelines > ) => void; +} + +export default function ImportExportControls( { + guidelines, + onImport, +}: ImportExportControlsProps ) { + const [ importError, setImportError ] = useState< string | null >( null ); + const [ importSuccess, setImportSuccess ] = useState( false ); + const fileInputRef = useRef< HTMLInputElement >( null ); + const { createSuccessNotice } = useDispatch( noticesStore ); + + const handleExport = () => { + if ( ! guidelines ) { + return; + } + + const exportData = { + __file: 'wp_guidelines', + version: 1, + exported_at: new Date().toISOString(), + status: guidelines.status, + guideline_categories: guidelines.guideline_categories, + }; + + const blob = new window.Blob( + [ JSON.stringify( exportData, null, 2 ) ], + { + type: 'application/json', + } + ); + + const url = URL.createObjectURL( blob ); + const link = document.createElement( 'a' ); + link.href = url; + link.download = `guidelines-${ + new Date().toISOString().split( 'T' )[ 0 ] + }.json`; + document.body.appendChild( link ); + link.click(); + document.body.removeChild( link ); + URL.revokeObjectURL( url ); + + createSuccessNotice( __( 'Guidelines exported successfully.' ), { + type: 'snackbar', + } ); + }; + + const handleFileSelect = ( + event: React.ChangeEvent< HTMLInputElement > + ) => { + const file = event.target.files?.[ 0 ]; + if ( ! file ) { + return; + } + + setImportError( null ); + setImportSuccess( false ); + + const reader = new window.FileReader(); + + reader.onload = ( e ) => { + try { + const data = JSON.parse( e.target?.result as string ); + + // Validate file type + if ( data.__file !== 'wp_guidelines' ) { + throw new Error( + __( 'Invalid file: not a guidelines JSON file.' ) + ); + } + + // Validate structure + if ( + ! data.guideline_categories || + typeof data.guideline_categories !== 'object' + ) { + throw new Error( + __( 'Invalid file: missing guideline_categories.' ) + ); + } + + onImport( { + status: data.status || 'draft', + guideline_categories: data.guideline_categories, + } ); + + setImportSuccess( true ); + } catch ( err ) { + setImportError( + ( err as Error ).message || + __( 'Failed to parse JSON file.' ) + ); + } + }; + + reader.onerror = () => { + setImportError( __( 'Failed to read file.' ) ); + }; + + reader.readAsText( file ); + event.target.value = ''; + }; + + return ( +
+ + { importError && ( + setImportError( null ) } + > + { importError } + + ) } + + { importSuccess && ( + setImportSuccess( false ) } + > + { __( + 'Guidelines imported. Review and save when ready.' + ) } + + ) } + +
+ { __( 'Export' ) } + + + { __( + 'Download your current guidelines as a JSON file.' + ) } + + + +
+ +
+ { __( 'Import' ) } + + + { __( + 'Load guidelines from a JSON file. Review before saving.' + ) } + + + + +
+
+
+ ); +} diff --git a/packages/content-guidelines/src/components/admin-page/index.tsx b/packages/content-guidelines/src/components/admin-page/index.tsx index 6215fc1dbeb287..f615ea147880fa 100644 --- a/packages/content-guidelines/src/components/admin-page/index.tsx +++ b/packages/content-guidelines/src/components/admin-page/index.tsx @@ -19,6 +19,7 @@ import CategoryPanel from './category-panel'; import BlockGuidelinesPanel from './block-guidelines-panel'; import PublishControls from './publish-controls'; import RevisionList from './revision-list'; +import ImportExportControls from './import-export-controls'; interface Category { slug: keyof GuidelineCategories; @@ -27,6 +28,13 @@ interface Category { } const CATEGORIES: Category[] = [ + { + slug: 'site', + label: __( 'Site Context' ), + description: __( + 'Describe your site goals, target audience, and overall content strategy.' + ), + }, { slug: 'copy', label: __( 'Copy Guidelines' ), @@ -41,13 +49,6 @@ const CATEGORIES: Category[] = [ 'Describe preferred image styles, colors, compositions, and visual aesthetics.' ), }, - { - slug: 'site', - label: __( 'Site Context' ), - description: __( - 'Describe your site goals, target audience, and overall content strategy.' - ), - }, { slug: 'blocks', label: __( 'Block-Specific Guidelines' ), @@ -84,13 +85,26 @@ export default function AdminPage() { fetchGuidelines(); }, [ fetchGuidelines ] ); - const handleSave = async () => { + const handleSave = async ( targetStatus?: 'draft' | 'published' ) => { if ( ! guidelines ) { return; } + + // If targetStatus provided, update status before saving + if ( targetStatus && targetStatus !== guidelines.status ) { + setStatus( targetStatus ); + } + try { - await saveGuidelines( guidelines ); - createSuccessNotice( __( 'Guidelines saved successfully.' ), { + await saveGuidelines( { + ...guidelines, + status: targetStatus || guidelines.status, + } ); + const message = + targetStatus === 'published' + ? __( 'Guidelines published successfully.' ) + : __( 'Guidelines saved.' ); + createSuccessNotice( message, { type: 'snackbar', } ); } catch ( err ) { @@ -101,10 +115,6 @@ export default function AdminPage() { } }; - const handleStatusChange = ( newStatus: 'draft' | 'published' ) => { - setStatus( newStatus ); - }; - const handleCategoryChange = ( category: string, value: string | BlockGuidelines @@ -116,6 +126,27 @@ export default function AdminPage() { } }; + const handleImport = ( + data: Partial< { + status: 'draft' | 'published'; + guideline_categories: GuidelineCategories; + } > + ) => { + if ( data.guideline_categories ) { + const categories = data.guideline_categories; + + ( + Object.keys( categories ) as Array< keyof GuidelineCategories > + ).forEach( ( key ) => { + updateCategory( key, categories[ key ] ); + } ); + } + + if ( data.status ) { + setStatus( data.status ); + } + }; + if ( isLoadingData ) { return (
@@ -149,7 +180,6 @@ export default function AdminPage() { isDirty={ isDirtyData } isSaving={ isSavingData } onSave={ handleSave } - onStatusChange={ handleStatusChange } /> @@ -184,7 +214,15 @@ export default function AdminPage() { ) } - + + +
); } diff --git a/packages/content-guidelines/src/components/admin-page/publish-controls.tsx b/packages/content-guidelines/src/components/admin-page/publish-controls.tsx index da347b2d4004a9..5df03cf079013f 100644 --- a/packages/content-guidelines/src/components/admin-page/publish-controls.tsx +++ b/packages/content-guidelines/src/components/admin-page/publish-controls.tsx @@ -1,26 +1,27 @@ /** * WordPress dependencies */ -import { Button, ToggleControl } from '@wordpress/components'; +import { Button } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; interface PublishControlsProps { status: 'draft' | 'published'; isDirty: boolean; isSaving: boolean; - onSave: () => void; - onStatusChange: ( status: 'draft' | 'published' ) => void; + onSave: ( targetStatus?: 'draft' | 'published' ) => void; } /** * Publish controls component for managing draft/published state. * - * @param props Component props. - * @param props.status Current status ('draft' or 'published'). - * @param props.isDirty Whether there are unsaved changes. - * @param props.isSaving Whether save is in progress. - * @param props.onSave Callback when save button is clicked. - * @param props.onStatusChange Callback when status toggle changes. + * - When draft: Shows "Save Draft" and "Publish" buttons + * - When published: Shows just "Save" button + * + * @param props Component props. + * @param props.status Current status ('draft' or 'published'). + * @param props.isDirty Whether there are unsaved changes. + * @param props.isSaving Whether save is in progress. + * @param props.onSave Callback when save button is clicked, with optional target status. * @return PublishControls component. */ export default function PublishControls( { @@ -28,37 +29,52 @@ export default function PublishControls( { isDirty, isSaving, onSave, - onStatusChange, }: PublishControlsProps ) { const isPublished = status === 'published'; + if ( isPublished ) { + // Already published: just show Save button + return ( +
+ + { __( 'Published' ) } + + +
+ ); + } + + // Draft: show Save Draft and Publish buttons return (
- - onStatusChange( checked ? 'published' : 'draft' ) - } - help={ - isPublished - ? __( - 'Guidelines are active and available to AI tools.' - ) - : __( 'Guidelines are saved as draft.' ) - } - /> +
); diff --git a/packages/content-guidelines/src/components/admin-page/revision-list.tsx b/packages/content-guidelines/src/components/admin-page/revision-list.tsx index 4ae3a2b544602d..4d6e98b14f086e 100644 --- a/packages/content-guidelines/src/components/admin-page/revision-list.tsx +++ b/packages/content-guidelines/src/components/admin-page/revision-list.tsx @@ -1,61 +1,70 @@ /** * WordPress dependencies */ -import { useState, useEffect } from '@wordpress/element'; -import apiFetch from '@wordpress/api-fetch'; +import { useEffect } from '@wordpress/element'; +import { useSelect, useDispatch } from '@wordpress/data'; import { dateI18n, getSettings } from '@wordpress/date'; -import { Spinner } from '@wordpress/components'; +import { Spinner, Button } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -interface Revision { - id: number; - date: string; - author_name: string; -} +/** + * Internal dependencies + */ +import { store } from '../../store'; +import type { GuidelineCategories } from '../../store/constants'; interface RevisionListProps { postId: number | undefined; + onRestore?: ( data: { guideline_categories: GuidelineCategories } ) => void; } /** * Revision list component for displaying revision history. * - * @param props Component props. - * @param props.postId The guidelines post ID. + * @param props Component props. + * @param props.postId The guidelines post ID. + * @param props.onRestore Callback when a revision is restored. * @return RevisionList component. */ -export default function RevisionList( { postId }: RevisionListProps ) { - const [ revisions, setRevisions ] = useState< Revision[] >( [] ); - const [ isLoading, setIsLoading ] = useState( false ); - const [ error, setError ] = useState< string | null >( null ); +export default function RevisionList( { + postId, + onRestore, +}: RevisionListProps ) { + const { revisions, isLoading, restoringId, error } = useSelect( + ( select ) => { + const selectors = select( store ); + return { + revisions: selectors.getRevisions(), + isLoading: selectors.isLoadingRevisions(), + restoringId: selectors.getRestoringRevisionId(), + error: selectors.getError(), + }; + }, + [] + ); + + const { fetchRevisions, restoreRevision } = useDispatch( store ); useEffect( () => { - if ( ! postId ) { - setRevisions( [] ); - return; + if ( postId ) { + fetchRevisions( postId ); } + }, [ postId, fetchRevisions ] ); - const fetchRevisions = async () => { - setIsLoading( true ); - setError( null ); - - try { - const response = await apiFetch< Revision[] >( { - path: `/__experimental/content-guidelines/${ postId }/revisions`, - } ); - setRevisions( response ); - } catch ( err ) { - setError( - ( err as Error ).message || - __( 'Failed to load revisions.' ) - ); - } finally { - setIsLoading( false ); - } - }; + const handleRestore = async ( revisionId: number ) => { + if ( ! postId || ! onRestore ) { + return; + } - fetchRevisions(); - }, [ postId ] ); + try { + const response = await restoreRevision( postId, revisionId ); + onRestore( { + guideline_categories: response.guideline_categories, + } ); + } catch { + // Error is handled by the store + } + }; if ( ! postId ) { return null; @@ -108,6 +117,21 @@ export default function RevisionList( { postId }: RevisionListProps ) { { revision.author_name || __( 'Unknown' ) } + { index > 0 && onRestore && ( + + ) } ) ) } diff --git a/packages/content-guidelines/src/store/actions.ts b/packages/content-guidelines/src/store/actions.ts index 3cdf12eb182e93..be70c465cc567b 100644 --- a/packages/content-guidelines/src/store/actions.ts +++ b/packages/content-guidelines/src/store/actions.ts @@ -10,6 +10,7 @@ import type { Guidelines, CategoryGuideline, BlockGuidelines, + Revision, } from './constants'; /** @@ -24,6 +25,12 @@ export const SAVE_GUIDELINES_ERROR = 'SAVE_GUIDELINES_ERROR' as const; export const UPDATE_CATEGORY = 'UPDATE_CATEGORY' as const; export const SET_STATUS = 'SET_STATUS' as const; export const RESET_CHANGES = 'RESET_CHANGES' as const; +export const FETCH_REVISIONS_START = 'FETCH_REVISIONS_START' as const; +export const FETCH_REVISIONS_SUCCESS = 'FETCH_REVISIONS_SUCCESS' as const; +export const FETCH_REVISIONS_ERROR = 'FETCH_REVISIONS_ERROR' as const; +export const RESTORE_REVISION_START = 'RESTORE_REVISION_START' as const; +export const RESTORE_REVISION_SUCCESS = 'RESTORE_REVISION_SUCCESS' as const; +export const RESTORE_REVISION_ERROR = 'RESTORE_REVISION_ERROR' as const; /** * Action type definitions @@ -43,7 +50,13 @@ export type Action = }; } | { type: typeof SET_STATUS; payload: 'draft' | 'published' } - | { type: typeof RESET_CHANGES }; + | { type: typeof RESET_CHANGES } + | { type: typeof FETCH_REVISIONS_START } + | { type: typeof FETCH_REVISIONS_SUCCESS; payload: Revision[] } + | { type: typeof FETCH_REVISIONS_ERROR; payload: string } + | { type: typeof RESTORE_REVISION_START; payload: number } + | { type: typeof RESTORE_REVISION_SUCCESS; payload: Guidelines } + | { type: typeof RESTORE_REVISION_ERROR; payload: string }; export interface ThunkArgs { dispatch: ( action: Action ) => void; @@ -153,3 +166,69 @@ export const setStatus = ( status: 'draft' | 'published' ): Action => ( { export const resetChanges = (): Action => ( { type: RESET_CHANGES, } ); + +/** + * Fetch revisions for a guidelines post. + * + * @param postId The guidelines post ID. + * @return Thunk action. + */ +export const fetchRevisions = + ( postId: number ) => + async ( { dispatch }: ThunkArgs ) => { + dispatch( { type: FETCH_REVISIONS_START } ); + + try { + const response = await apiFetch< Revision[] >( { + path: `/__experimental/content-guidelines/${ postId }/revisions`, + } ); + + dispatch( { + type: FETCH_REVISIONS_SUCCESS, + payload: response, + } ); + } catch ( error ) { + dispatch( { + type: FETCH_REVISIONS_ERROR, + payload: + ( error as Error ).message || 'Failed to fetch revisions', + } ); + } + }; + +/** + * Restore a revision to the main guidelines post. + * + * @param postId The guidelines post ID. + * @param revisionId The revision ID to restore. + * @return Thunk action. + */ +export const restoreRevision = + ( postId: number, revisionId: number ) => + async ( { dispatch }: ThunkArgs ) => { + dispatch( { type: RESTORE_REVISION_START, payload: revisionId } ); + + try { + const response = await apiFetch< Guidelines >( { + path: `/__experimental/content-guidelines/${ postId }/revisions/${ revisionId }/restore`, + method: 'POST', + } ); + + dispatch( { + type: RESTORE_REVISION_SUCCESS, + payload: response, + } ); + + // Refresh revisions after restore. + dispatch( fetchRevisions( postId ) as unknown as Action ); + + return response; + } catch ( error ) { + dispatch( { + type: RESTORE_REVISION_ERROR, + payload: + ( error as Error ).message || 'Failed to restore revision', + } ); + throw error; + } + }; diff --git a/packages/content-guidelines/src/store/constants.ts b/packages/content-guidelines/src/store/constants.ts index 141f6cb6f3386e..08bb377d0e3132 100644 --- a/packages/content-guidelines/src/store/constants.ts +++ b/packages/content-guidelines/src/store/constants.ts @@ -37,12 +37,21 @@ export interface Guidelines { author_name?: string; } +export interface Revision { + id: number; + date: string; + author_name: string; +} + export interface State { guidelines: Guidelines | null; originalGuidelines: Guidelines | null; isLoading: boolean; isSaving: boolean; error: string | null; + revisions: Revision[]; + isLoadingRevisions: boolean; + isRestoring: number | null; } /** @@ -77,4 +86,7 @@ export const DEFAULT_STATE: State = { isLoading: false, isSaving: false, error: null, + revisions: [], + isLoadingRevisions: false, + isRestoring: null, }; diff --git a/packages/content-guidelines/src/store/index.ts b/packages/content-guidelines/src/store/index.ts index fd31a8c57974be..b881c9efef23f5 100644 --- a/packages/content-guidelines/src/store/index.ts +++ b/packages/content-guidelines/src/store/index.ts @@ -13,6 +13,8 @@ import { updateCategory, setStatus, resetChanges, + fetchRevisions, + restoreRevision, } from './actions'; import * as selectors from './selectors'; import { STORE_NAME } from './constants'; @@ -26,6 +28,8 @@ const actions = { updateCategory, setStatus, resetChanges, + fetchRevisions, + restoreRevision, }; /** diff --git a/packages/content-guidelines/src/store/reducer.ts b/packages/content-guidelines/src/store/reducer.ts index 790f8fd0908a5b..78ae7143120e0f 100644 --- a/packages/content-guidelines/src/store/reducer.ts +++ b/packages/content-guidelines/src/store/reducer.ts @@ -11,6 +11,12 @@ import { UPDATE_CATEGORY, SET_STATUS, RESET_CHANGES, + FETCH_REVISIONS_START, + FETCH_REVISIONS_SUCCESS, + FETCH_REVISIONS_ERROR, + RESTORE_REVISION_START, + RESTORE_REVISION_SUCCESS, + RESTORE_REVISION_ERROR, } from './actions'; import type { Action } from './actions'; import { DEFAULT_STATE, DEFAULT_CATEGORIES } from './constants'; @@ -125,6 +131,55 @@ export default function reducer( : null, }; + case FETCH_REVISIONS_START: + return { + ...state, + isLoadingRevisions: true, + }; + + case FETCH_REVISIONS_SUCCESS: + return { + ...state, + revisions: action.payload, + isLoadingRevisions: false, + }; + + case FETCH_REVISIONS_ERROR: + return { + ...state, + isLoadingRevisions: false, + error: action.payload, + }; + + case RESTORE_REVISION_START: + return { + ...state, + isRestoring: action.payload, + }; + + case RESTORE_REVISION_SUCCESS: { + const guidelines: Guidelines = { + ...action.payload, + guideline_categories: { + ...DEFAULT_CATEGORIES, + ...( action.payload.guideline_categories || {} ), + }, + }; + return { + ...state, + guidelines, + originalGuidelines: JSON.parse( JSON.stringify( guidelines ) ), + isRestoring: null, + }; + } + + case RESTORE_REVISION_ERROR: + return { + ...state, + isRestoring: null, + error: action.payload, + }; + default: return state; } diff --git a/packages/content-guidelines/src/store/selectors.ts b/packages/content-guidelines/src/store/selectors.ts index 90d23d762b0006..59d39b94c9a168 100644 --- a/packages/content-guidelines/src/store/selectors.ts +++ b/packages/content-guidelines/src/store/selectors.ts @@ -6,6 +6,7 @@ import type { Guidelines, CategoryGuideline, BlockGuidelines, + Revision, } from './constants'; /** @@ -86,3 +87,29 @@ export const isSaving = ( state: State ): boolean => state.isSaving; * @return Error message or null. */ export const getError = ( state: State ): string | null => state.error; + +/** + * Get the list of revisions. + * + * @param state Store state. + * @return Array of revisions. + */ +export const getRevisions = ( state: State ): Revision[] => state.revisions; + +/** + * Check if revisions are currently being loaded. + * + * @param state Store state. + * @return True if loading revisions. + */ +export const isLoadingRevisions = ( state: State ): boolean => + state.isLoadingRevisions; + +/** + * Get the ID of the revision currently being restored, if any. + * + * @param state Store state. + * @return Revision ID being restored or null. + */ +export const getRestoringRevisionId = ( state: State ): number | null => + state.isRestoring; diff --git a/packages/content-guidelines/src/style.scss b/packages/content-guidelines/src/style.scss index 20a710af896664..afd9001175ece2 100644 --- a/packages/content-guidelines/src/style.scss +++ b/packages/content-guidelines/src/style.scss @@ -46,21 +46,19 @@ .content-guidelines-publish-controls { display: flex; - align-items: flex-start; - justify-content: space-between; + align-items: center; + justify-content: flex-end; padding: 16px; background: #fff; border: 1px solid #e0e0e0; border-radius: 2px; - gap: 16px; - flex-wrap: wrap; - - .components-toggle-control { - margin-bottom: 0; - } + gap: 12px; - &__save-button { - flex-shrink: 0; + &__status { + margin-right: auto; + font-size: 13px; + font-weight: 600; + color: #007017; } } @@ -189,4 +187,33 @@ &__author { color: #2271b1; } + + &__restore { + flex-shrink: 0; + margin-left: auto; + } +} + +.content-guidelines-import-export { + margin-top: 24px; + padding: 16px; + background: #f6f7f7; + border: 1px solid #e0e0e0; + border-radius: 2px; + + &__section { + padding: 16px 0; + + &:first-of-type { + padding-top: 0; + } + + &:not(:last-of-type) { + border-bottom: 1px solid #e0e0e0; + } + + &:last-of-type { + padding-bottom: 0; + } + } } From 9c85705d78ffe7779d760202dc1d8f970fbea025 Mon Sep 17 00:00:00 2001 From: Aagam Shah Date: Tue, 27 Jan 2026 10:34:29 +0530 Subject: [PATCH 03/36] Content Guidelines: Add two-column layout and revision pagination - Implement responsive two-column layout with guidelines on left and sidebar on right - Add pagination support for revision history (5 per page) with Previous/Next controls - Update REST API to support page and per_page parameters with X-WP-Total headers - Open Site Context panel by default as the first category - Combine Export/Import controls into a single section with side-by-side buttons - Remove max-width constraint for better responsive behavior Co-Authored-By: Claude Opus 4.5 --- .../class-rest-controller.php | 39 +++++- .../components/admin-page/category-panel.tsx | 5 +- .../admin-page/import-export-controls.tsx | 54 ++++---- .../src/components/admin-page/index.tsx | 91 +++++++------- .../components/admin-page/revision-list.tsx | 115 +++++++++++++----- .../content-guidelines/src/store/actions.ts | 39 ++++-- .../content-guidelines/src/store/constants.ts | 14 +++ .../content-guidelines/src/store/reducer.ts | 3 +- .../content-guidelines/src/store/selectors.ts | 28 +++++ packages/content-guidelines/src/style.scss | 59 ++++++--- 10 files changed, 313 insertions(+), 134 deletions(-) diff --git a/lib/experimental/content-guidelines/class-rest-controller.php b/lib/experimental/content-guidelines/class-rest-controller.php index a56aecacccb5df..ab7a5d9eea240d 100644 --- a/lib/experimental/content-guidelines/class-rest-controller.php +++ b/lib/experimental/content-guidelines/class-rest-controller.php @@ -111,10 +111,23 @@ public function register_routes() { 'callback' => array( $this, 'get_revisions' ), 'permission_callback' => array( $this, 'get_item_permissions_check' ), 'args' => array( - 'id' => array( + 'id' => array( 'description' => __( 'Unique identifier for the guidelines.', 'gutenberg' ), 'type' => 'integer', ), + 'page' => array( + 'description' => __( 'Current page of the revisions collection.', 'gutenberg' ), + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + ), + 'per_page' => array( + 'description' => __( 'Maximum number of revisions to be returned per page.', 'gutenberg' ), + 'type' => 'integer', + 'default' => 5, + 'minimum' => 1, + 'maximum' => 100, + ), ), ), ) @@ -416,10 +429,26 @@ public function get_revisions( $request ) { ); } + $page = $request->get_param( 'page' ); + $per_page = $request->get_param( 'per_page' ); + $offset = ( $page - 1 ) * $per_page; + + // Get total count of revisions. + $all_revision_ids = wp_get_post_revisions( + $post->ID, + array( + 'fields' => 'ids', + ) + ); + $total_items = count( $all_revision_ids ); + $total_pages = (int) ceil( $total_items / $per_page ); + + // Get paginated revisions. $revisions = wp_get_post_revisions( $post->ID, array( - 'posts_per_page' => 10, + 'posts_per_page' => $per_page, + 'offset' => $offset, 'orderby' => 'date', 'order' => 'DESC', ) @@ -437,7 +466,11 @@ public function get_revisions( $request ) { ); } - return rest_ensure_response( $data ); + $response = rest_ensure_response( $data ); + $response->header( 'X-WP-Total', $total_items ); + $response->header( 'X-WP-TotalPages', $total_pages ); + + return $response; } /** diff --git a/packages/content-guidelines/src/components/admin-page/category-panel.tsx b/packages/content-guidelines/src/components/admin-page/category-panel.tsx index ab22892de251af..80d5770dd3798b 100644 --- a/packages/content-guidelines/src/components/admin-page/category-panel.tsx +++ b/packages/content-guidelines/src/components/admin-page/category-panel.tsx @@ -8,6 +8,7 @@ interface CategoryPanelProps { description: string; value: string; onChange: ( value: string ) => void; + initialOpen?: boolean; } /** @@ -18,6 +19,7 @@ interface CategoryPanelProps { * @param props.description Help text for the textarea. * @param props.value Current value of the textarea. * @param props.onChange Callback when value changes. + * @param props.initialOpen Whether the panel is initially open. * @return CategoryPanel component. */ export default function CategoryPanel( { @@ -25,9 +27,10 @@ export default function CategoryPanel( { description, value, onChange, + initialOpen = false, }: CategoryPanelProps ) { return ( - + - + + { __( 'Export / Import' ) } + + { __( + 'Export your guidelines as JSON or import from a file.' + ) } + + { importError && ( ) } -
- { __( 'Export' ) } - - - { __( - 'Download your current guidelines as a JSON file.' - ) } - - + + + + + -
- -
- { __( 'Import' ) } - - - { __( - 'Load guidelines from a JSON file. Review before saving.' - ) } - - - -
+
); diff --git a/packages/content-guidelines/src/components/admin-page/index.tsx b/packages/content-guidelines/src/components/admin-page/index.tsx index f615ea147880fa..3836c208dfd48c 100644 --- a/packages/content-guidelines/src/components/admin-page/index.tsx +++ b/packages/content-guidelines/src/components/admin-page/index.tsx @@ -182,47 +182,56 @@ export default function AdminPage() { onSave={ handleSave } /> - - { CATEGORIES.map( ( category ) => - category.slug === 'blocks' ? ( - - handleCategoryChange( 'blocks', value ) - } - /> - ) : ( - - handleCategoryChange( category.slug, value ) - } - /> - ) - ) } - - - - - +
+
+ + { CATEGORIES.map( ( category, index ) => + category.slug === 'blocks' ? ( + + handleCategoryChange( 'blocks', value ) + } + /> + ) : ( + + handleCategoryChange( + category.slug, + value + ) + } + initialOpen={ index === 0 } + /> + ) + ) } + +
+ +
); } diff --git a/packages/content-guidelines/src/components/admin-page/revision-list.tsx b/packages/content-guidelines/src/components/admin-page/revision-list.tsx index 4d6e98b14f086e..4af23ca0f8f97c 100644 --- a/packages/content-guidelines/src/components/admin-page/revision-list.tsx +++ b/packages/content-guidelines/src/components/admin-page/revision-list.tsx @@ -1,11 +1,17 @@ /** * WordPress dependencies */ -import { useEffect } from '@wordpress/element'; +import { useEffect, useCallback } from '@wordpress/element'; import { useSelect, useDispatch } from '@wordpress/data'; import { dateI18n, getSettings } from '@wordpress/date'; -import { Spinner, Button } from '@wordpress/components'; +import { + Spinner, + Button, + __experimentalHStack as HStack, + __experimentalText as Text, +} from '@wordpress/components'; import { __ } from '@wordpress/i18n'; +import { chevronLeft, chevronRight } from '@wordpress/icons'; /** * Internal dependencies @@ -30,27 +36,42 @@ export default function RevisionList( { postId, onRestore, }: RevisionListProps ) { - const { revisions, isLoading, restoringId, error } = useSelect( - ( select ) => { - const selectors = select( store ); - return { - revisions: selectors.getRevisions(), - isLoading: selectors.isLoadingRevisions(), - restoringId: selectors.getRestoringRevisionId(), - error: selectors.getError(), - }; - }, - [] - ); + const { + revisions, + isLoading, + restoringId, + error, + currentPage, + totalPages, + } = useSelect( ( select ) => { + const selectors = select( store ); + return { + revisions: selectors.getRevisions(), + isLoading: selectors.isLoadingRevisions(), + restoringId: selectors.getRestoringRevisionId(), + error: selectors.getError(), + currentPage: selectors.getRevisionCurrentPage(), + totalPages: selectors.getRevisionTotalPages(), + }; + }, [] ); const { fetchRevisions, restoreRevision } = useDispatch( store ); useEffect( () => { if ( postId ) { - fetchRevisions( postId ); + fetchRevisions( postId, 1, 5 ); } }, [ postId, fetchRevisions ] ); + const handlePageChange = useCallback( + ( page: number ) => { + if ( postId ) { + fetchRevisions( postId, page, 5 ); + } + }, + [ postId, fetchRevisions ] + ); + const handleRestore = async ( revisionId: number ) => { if ( ! postId || ! onRestore ) { return; @@ -107,7 +128,7 @@ export default function RevisionList( { { revisions.map( ( revision, index ) => (
  • - { index === 0 + { currentPage === 1 && index === 0 ? __( 'Current' ) : `#${ revisions.length - index }` } @@ -117,24 +138,56 @@ export default function RevisionList( { { revision.author_name || __( 'Unknown' ) } - { index > 0 && onRestore && ( - - ) } + { ! ( currentPage === 1 && index === 0 ) && + onRestore && ( + + ) }
  • ) ) } + { totalPages > 1 && ( +
    + +
    + ) } ); } diff --git a/packages/content-guidelines/src/store/actions.ts b/packages/content-guidelines/src/store/actions.ts index be70c465cc567b..9469dfd8cb2dee 100644 --- a/packages/content-guidelines/src/store/actions.ts +++ b/packages/content-guidelines/src/store/actions.ts @@ -11,6 +11,7 @@ import type { CategoryGuideline, BlockGuidelines, Revision, + RevisionPagination, } from './constants'; /** @@ -52,7 +53,10 @@ export type Action = | { type: typeof SET_STATUS; payload: 'draft' | 'published' } | { type: typeof RESET_CHANGES } | { type: typeof FETCH_REVISIONS_START } - | { type: typeof FETCH_REVISIONS_SUCCESS; payload: Revision[] } + | { + type: typeof FETCH_REVISIONS_SUCCESS; + payload: { revisions: Revision[]; pagination: RevisionPagination }; + } | { type: typeof FETCH_REVISIONS_ERROR; payload: string } | { type: typeof RESTORE_REVISION_START; payload: number } | { type: typeof RESTORE_REVISION_SUCCESS; payload: Guidelines } @@ -170,22 +174,43 @@ export const resetChanges = (): Action => ( { /** * Fetch revisions for a guidelines post. * - * @param postId The guidelines post ID. + * @param postId The guidelines post ID. + * @param page The page number (default: 1). + * @param perPage Number of revisions per page (default: 5). * @return Thunk action. */ export const fetchRevisions = - ( postId: number ) => + ( postId: number, page: number = 1, perPage: number = 5 ) => async ( { dispatch }: ThunkArgs ) => { dispatch( { type: FETCH_REVISIONS_START } ); try { - const response = await apiFetch< Revision[] >( { - path: `/__experimental/content-guidelines/${ postId }/revisions`, - } ); + const response = ( await apiFetch( { + path: `/__experimental/content-guidelines/${ postId }/revisions?page=${ page }&per_page=${ perPage }`, + parse: false, + } ) ) as Response; + + const revisions = ( await response.json() ) as Revision[]; + const totalItems = parseInt( + response.headers.get( 'X-WP-Total' ) || '0', + 10 + ); + const totalPages = parseInt( + response.headers.get( 'X-WP-TotalPages' ) || '1', + 10 + ); dispatch( { type: FETCH_REVISIONS_SUCCESS, - payload: response, + payload: { + revisions, + pagination: { + currentPage: page, + totalPages, + totalItems, + perPage, + }, + }, } ); } catch ( error ) { dispatch( { diff --git a/packages/content-guidelines/src/store/constants.ts b/packages/content-guidelines/src/store/constants.ts index 08bb377d0e3132..2666b6960a812b 100644 --- a/packages/content-guidelines/src/store/constants.ts +++ b/packages/content-guidelines/src/store/constants.ts @@ -43,6 +43,13 @@ export interface Revision { author_name: string; } +export interface RevisionPagination { + currentPage: number; + totalPages: number; + totalItems: number; + perPage: number; +} + export interface State { guidelines: Guidelines | null; originalGuidelines: Guidelines | null; @@ -52,6 +59,7 @@ export interface State { revisions: Revision[]; isLoadingRevisions: boolean; isRestoring: number | null; + revisionPagination: RevisionPagination; } /** @@ -89,4 +97,10 @@ export const DEFAULT_STATE: State = { revisions: [], isLoadingRevisions: false, isRestoring: null, + revisionPagination: { + currentPage: 1, + totalPages: 1, + totalItems: 0, + perPage: 5, + }, }; diff --git a/packages/content-guidelines/src/store/reducer.ts b/packages/content-guidelines/src/store/reducer.ts index 78ae7143120e0f..183b25212d4410 100644 --- a/packages/content-guidelines/src/store/reducer.ts +++ b/packages/content-guidelines/src/store/reducer.ts @@ -140,7 +140,8 @@ export default function reducer( case FETCH_REVISIONS_SUCCESS: return { ...state, - revisions: action.payload, + revisions: action.payload.revisions, + revisionPagination: action.payload.pagination, isLoadingRevisions: false, }; diff --git a/packages/content-guidelines/src/store/selectors.ts b/packages/content-guidelines/src/store/selectors.ts index 59d39b94c9a168..82e6b9e59b47c1 100644 --- a/packages/content-guidelines/src/store/selectors.ts +++ b/packages/content-guidelines/src/store/selectors.ts @@ -7,6 +7,7 @@ import type { CategoryGuideline, BlockGuidelines, Revision, + RevisionPagination, } from './constants'; /** @@ -113,3 +114,30 @@ export const isLoadingRevisions = ( state: State ): boolean => */ export const getRestoringRevisionId = ( state: State ): number | null => state.isRestoring; + +/** + * Get the revision pagination state. + * + * @param state Store state. + * @return Pagination state object. + */ +export const getRevisionPagination = ( state: State ): RevisionPagination => + state.revisionPagination; + +/** + * Get the current revision page number. + * + * @param state Store state. + * @return Current page number. + */ +export const getRevisionCurrentPage = ( state: State ): number => + state.revisionPagination.currentPage; + +/** + * Get the total number of revision pages. + * + * @param state Store state. + * @return Total number of pages. + */ +export const getRevisionTotalPages = ( state: State ): number => + state.revisionPagination.totalPages; diff --git a/packages/content-guidelines/src/style.scss b/packages/content-guidelines/src/style.scss index afd9001175ece2..9c6b567318beb1 100644 --- a/packages/content-guidelines/src/style.scss +++ b/packages/content-guidelines/src/style.scss @@ -1,6 +1,5 @@ .content-guidelines-admin { - max-width: 800px; - margin: 20px 20px 0 2px; + margin: 20px 20px 20px 2px; &--loading { display: flex; @@ -31,9 +30,41 @@ margin: 0; } - &__panel { + &__layout { + display: grid; + grid-template-columns: 1fr 320px; + gap: 24px; margin-top: 16px; + // Large screens: main content can grow, sidebar stays fixed + @media (min-width: 1400px) { + grid-template-columns: 1fr 380px; + } + + // Medium screens: stack vertically + @media (max-width: 782px) { + grid-template-columns: 1fr; + } + } + + &__main { + min-width: 0; + } + + &__sidebar { + display: flex; + flex-direction: column; + gap: 24px; + + .content-guidelines-revisions, + .content-guidelines-import-export { + margin-top: 0; + } + } + + &__panel { + margin-top: 0; + .components-panel__body { border-top: 1px solid #e0e0e0; @@ -160,6 +191,12 @@ margin: 0; padding: 0; } + + &__pagination { + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid #f0f0f0; + } } .revision-item { @@ -200,20 +237,4 @@ background: #f6f7f7; border: 1px solid #e0e0e0; border-radius: 2px; - - &__section { - padding: 16px 0; - - &:first-of-type { - padding-top: 0; - } - - &:not(:last-of-type) { - border-bottom: 1px solid #e0e0e0; - } - - &:last-of-type { - padding-bottom: 0; - } - } } From 6d6710830377d1722a10eb9128c26357d9bb501a Mon Sep 17 00:00:00 2001 From: Aagam Shah Date: Tue, 27 Jan 2026 10:39:46 +0530 Subject: [PATCH 04/36] Content Guidelines: Use table layout for revision history - Convert revision list from flexbox to table for proper column alignment - Fix revision numbering to account for pagination - Keep action column empty for current revision instead of showing dash Co-Authored-By: Claude Opus 4.5 --- .../components/admin-page/revision-list.tsx | 89 +++++++++++-------- packages/content-guidelines/src/style.scss | 42 ++++----- 2 files changed, 74 insertions(+), 57 deletions(-) diff --git a/packages/content-guidelines/src/components/admin-page/revision-list.tsx b/packages/content-guidelines/src/components/admin-page/revision-list.tsx index 4af23ca0f8f97c..8c96486029e056 100644 --- a/packages/content-guidelines/src/components/admin-page/revision-list.tsx +++ b/packages/content-guidelines/src/components/admin-page/revision-list.tsx @@ -43,15 +43,20 @@ export default function RevisionList( { error, currentPage, totalPages, + totalItems, + perPage, } = useSelect( ( select ) => { const selectors = select( store ); + const pagination = selectors.getRevisionPagination(); return { revisions: selectors.getRevisions(), isLoading: selectors.isLoadingRevisions(), restoringId: selectors.getRestoringRevisionId(), error: selectors.getError(), - currentPage: selectors.getRevisionCurrentPage(), - totalPages: selectors.getRevisionTotalPages(), + currentPage: pagination.currentPage, + totalPages: pagination.totalPages, + totalItems: pagination.totalItems, + perPage: pagination.perPage, }; }, [] ); @@ -121,44 +126,54 @@ export default function RevisionList( { const dateFormat = getSettings().formats.datetime; + const isCurrent = ( index: number ) => currentPage === 1 && index === 0; + + // Calculate revision number based on pagination + const getRevisionNumber = ( index: number ) => { + const offset = ( currentPage - 1 ) * perPage; + return totalItems - offset - index; + }; + return (

    { __( 'Revision History' ) }

    -
      - { revisions.map( ( revision, index ) => ( -
    • - - { currentPage === 1 && index === 0 - ? __( 'Current' ) - : `#${ revisions.length - index }` } - - - { dateI18n( dateFormat, revision.date ) } - - - { revision.author_name || __( 'Unknown' ) } - - { ! ( currentPage === 1 && index === 0 ) && - onRestore && ( - - ) } -
    • - ) ) } -
    + + + { revisions.map( ( revision, index ) => ( + + + + + + + ) ) } + +
    + { isCurrent( index ) + ? __( 'Current' ) + : `#${ getRevisionNumber( index ) }` } + + { dateI18n( dateFormat, revision.date ) } + + { revision.author_name || __( 'Unknown' ) } + + { ! isCurrent( index ) && onRestore && ( + + ) } +
    { totalPages > 1 && (
    diff --git a/packages/content-guidelines/src/style.scss b/packages/content-guidelines/src/style.scss index 9c6b567318beb1..c69007885677d7 100644 --- a/packages/content-guidelines/src/style.scss +++ b/packages/content-guidelines/src/style.scss @@ -186,10 +186,25 @@ margin: 0; } - &__list { - list-style: none; - margin: 0; - padding: 0; + &__table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + td { + padding: 8px 8px 8px 0; + border-bottom: 1px solid #f0f0f0; + vertical-align: middle; + + &:last-child { + padding-right: 0; + text-align: right; + } + } + + tr:last-child td { + border-bottom: none; + } } &__pagination { @@ -200,24 +215,12 @@ } .revision-item { - display: flex; - align-items: center; - padding: 8px 0; - border-bottom: 1px solid #f0f0f0; - gap: 16px; - font-size: 13px; - - &:last-child { - border-bottom: none; - } - &__number { font-weight: 600; - min-width: 60px; + white-space: nowrap; } &__date { - flex: 1; color: #646970; } @@ -225,9 +228,8 @@ color: #2271b1; } - &__restore { - flex-shrink: 0; - margin-left: auto; + &__action { + color: #646970; } } From 4c948b93ccf0e206369111337543bf23b20d0d7d Mon Sep 17 00:00:00 2001 From: Aagam Shah Date: Tue, 27 Jan 2026 10:49:45 +0530 Subject: [PATCH 05/36] Content Guidelines: Use Update button for published state Change the Save button to Update when guidelines are published to match the standard WordPress admin pattern for published content. Also update the Published status text to use neutral styling instead of link-like green color. Co-Authored-By: Claude Opus 4.5 --- .../src/components/admin-page/publish-controls.tsx | 2 +- packages/content-guidelines/src/style.scss | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/content-guidelines/src/components/admin-page/publish-controls.tsx b/packages/content-guidelines/src/components/admin-page/publish-controls.tsx index 5df03cf079013f..a05b62b6e9a804 100644 --- a/packages/content-guidelines/src/components/admin-page/publish-controls.tsx +++ b/packages/content-guidelines/src/components/admin-page/publish-controls.tsx @@ -47,7 +47,7 @@ export default function PublishControls( { accessibleWhenDisabled __next40pxDefaultSize > - { isSaving ? __( 'Saving…' ) : __( 'Save' ) } + { isSaving ? __( 'Updating…' ) : __( 'Update' ) }
    ); diff --git a/packages/content-guidelines/src/style.scss b/packages/content-guidelines/src/style.scss index c69007885677d7..2333eb8aff1028 100644 --- a/packages/content-guidelines/src/style.scss +++ b/packages/content-guidelines/src/style.scss @@ -88,8 +88,8 @@ &__status { margin-right: auto; font-size: 13px; - font-weight: 600; - color: #007017; + font-weight: 400; + color: #1e1e1e; } } From 31ac24190fe06eb2a346664153f7c8008f24f636 Mon Sep 17 00:00:00 2001 From: Aagam Shah Date: Tue, 27 Jan 2026 11:04:56 +0530 Subject: [PATCH 06/36] Content Guidelines: Fix JSON encoding for special characters Add robust JSON encoding/decoding helpers following the WordPress Global Styles pattern. This fixes an issue where content containing quotes and special characters would corrupt the stored JSON data. Changes: - Add JSON encoding flags (JSON_HEX_TAG, JSON_HEX_AMP) for XSS prevention - Add encode_json_for_storage() helper with wp_slash() for DB compatibility - Add decode_json_from_storage() helper with json_last_error() checking - Update all JSON operations to use consistent helper methods Co-Authored-By: Claude Opus 4.5 --- .../class-rest-controller.php | 62 ++++++++++++++----- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/lib/experimental/content-guidelines/class-rest-controller.php b/lib/experimental/content-guidelines/class-rest-controller.php index ab7a5d9eea240d..f5baeace3351e4 100644 --- a/lib/experimental/content-guidelines/class-rest-controller.php +++ b/lib/experimental/content-guidelines/class-rest-controller.php @@ -21,6 +21,17 @@ class Gutenberg_Content_Guidelines_REST_Controller extends WP_REST_Controller { */ protected $post_type = 'wp_guidelines'; + /** + * JSON encoding flags for consistent and secure encoding. + * + * - JSON_UNESCAPED_SLASHES: Prevents escaping forward slashes (cleaner output) + * - JSON_HEX_TAG: Encodes < and > as \u003C and \u003E (XSS prevention) + * - JSON_HEX_AMP: Encodes & as \u0026 (XSS prevention) + * + * @var int + */ + protected $json_encode_flags = JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP; + /** * Constructor. */ @@ -318,7 +329,7 @@ public function create_item( $request ) { 'post_type' => $this->post_type, 'post_status' => isset( $prepared['status'] ) && 'published' === $prepared['status'] ? 'publish' : 'draft', 'post_title' => __( 'Content Guidelines', 'gutenberg' ), - 'post_content' => wp_json_encode( $prepared['content'] ), + 'post_content' => $this->encode_json_for_storage( $prepared['content'] ), ), true ); @@ -351,16 +362,12 @@ public function update_item( $request ) { $prepared = $this->prepare_item_for_database( $request ); // Get existing content and merge with new content. - $existing_content = json_decode( $post->post_content, true ); - if ( ! is_array( $existing_content ) ) { - $existing_content = array(); - } - - $new_content = array_merge( $existing_content, $prepared['content'] ); + $existing_content = $this->decode_json_from_storage( $post->post_content ); + $new_content = array_merge( $existing_content, $prepared['content'] ); $update_args = array( 'ID' => $post->ID, - 'post_content' => wp_json_encode( $new_content ), + 'post_content' => $this->encode_json_for_storage( $new_content ), ); if ( isset( $prepared['status'] ) ) { @@ -500,10 +507,7 @@ public function get_revision( $request ) { ); } - $content = json_decode( $revision->post_content, true ); - if ( ! is_array( $content ) ) { - $content = array(); - } + $content = $this->decode_json_from_storage( $revision->post_content ); $author = get_user_by( 'id', $revision->post_author ); @@ -584,10 +588,7 @@ public function restore_revision( $request ) { * @return WP_REST_Response Response object. */ public function prepare_item_for_response( $post, $request ) { - $content = json_decode( $post->post_content, true ); - if ( ! is_array( $content ) ) { - $content = array(); - } + $content = $this->decode_json_from_storage( $post->post_content ); $author = get_user_by( 'id', $post->post_author ); @@ -660,6 +661,35 @@ protected function get_validated_post( $id ) { return $post; } + /** + * Encodes data as JSON for storage in post_content. + * + * Uses consistent encoding flags and wp_slash() for WordPress database compatibility. + * This ensures special characters (quotes, HTML entities) are properly escaped. + * + * @param array $data Data to encode. + * @return string Slashed JSON string ready for wp_insert_post/wp_update_post. + */ + protected function encode_json_for_storage( $data ) { + return wp_slash( wp_json_encode( $data, $this->json_encode_flags ) ); + } + + /** + * Decodes JSON from post_content with error handling. + * + * @param string $json_string JSON string to decode. + * @return array Decoded array, or empty array if decoding fails. + */ + protected function decode_json_from_storage( $json_string ) { + $decoded = json_decode( $json_string, true ); + + if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) { + return array(); + } + + return $decoded; + } + /** * Gets the single guidelines post. * From a5749cb771ea264648cdf206d46918ab49d00baf Mon Sep 17 00:00:00 2001 From: Aagam Shah Date: Tue, 27 Jan 2026 11:21:15 +0530 Subject: [PATCH 07/36] Content Guidelines: Simplify and refactor code for clarity Extract helper functions to eliminate code duplication: - normalizeGuidelines() and deepClone() in reducer.ts - extractBlockTitle() and truncateText() in block-guidelines-panel.tsx Improve code readability: - Replace nested ternaries with if/else blocks in index.tsx - Use early returns for null checks in reducer cases - Extract type casts into local variables Remove redundant selectors (getRevisionCurrentPage, getRevisionTotalPages) that duplicated data already available via getRevisionPagination. Co-Authored-By: Claude Opus 4.5 --- .../admin-page/block-guidelines-panel.tsx | 63 +++++++------ .../src/components/admin-page/index.tsx | 64 +++++++------ .../content-guidelines/src/store/reducer.ts | 90 ++++++++++--------- .../content-guidelines/src/store/selectors.ts | 18 ---- 4 files changed, 122 insertions(+), 113 deletions(-) diff --git a/packages/content-guidelines/src/components/admin-page/block-guidelines-panel.tsx b/packages/content-guidelines/src/components/admin-page/block-guidelines-panel.tsx index 3f0a64bf053955..6d1aaafe3d3244 100644 --- a/packages/content-guidelines/src/components/admin-page/block-guidelines-panel.tsx +++ b/packages/content-guidelines/src/components/admin-page/block-guidelines-panel.tsx @@ -33,6 +33,36 @@ interface BlockGuidelinesPanelProps { onChange: ( value: BlockGuidelines ) => void; } +/** + * Extracts the title string from a block type response. + * + * @param block Block type response object. + * @return Normalized title string. + */ +function extractBlockTitle( block: BlockTypeResponse ): string { + if ( typeof block.title === 'string' ) { + return block.title; + } + if ( block.title && typeof block.title === 'object' ) { + return block.title.rendered; + } + return block.name; +} + +/** + * Truncates text to a maximum length with ellipsis. + * + * @param text Text to truncate. + * @param maxLength Maximum length before truncation. + * @return Truncated text with ellipsis if needed. + */ +function truncateText( text: string, maxLength: number ): string { + if ( text.length <= maxLength ) { + return text; + } + return `${ text.substring( 0, maxLength ) }...`; +} + /** * Block guidelines panel component for managing per-block guidelines. * @@ -49,31 +79,17 @@ export default function BlockGuidelinesPanel( { const [ blockTypes, setBlockTypes ] = useState< BlockType[] >( [] ); const [ isLoading, setIsLoading ] = useState( true ); - // Fetch block types from REST API. useEffect( () => { - const fetchBlockTypes = async () => { + async function fetchBlockTypes(): Promise< void > { try { const response = await apiFetch< BlockTypeResponse[] >( { path: '/wp/v2/block-types', } ); - // Process response to normalize title and filter out invalid entries. const processedBlocks = response - .map( ( block ) => { - let title = ''; - if ( typeof block.title === 'string' ) { - title = block.title; - } else if ( - block.title && - typeof block.title === 'object' && - 'rendered' in block.title - ) { - title = block.title.rendered; - } - return { - name: block.name, - title: title || block.name, // Fallback to block name. - }; - } ) + .map( ( block ) => ( { + name: block.name, + title: extractBlockTitle( block ), + } ) ) .filter( ( block ) => block.name && block.title ); setBlockTypes( processedBlocks ); } catch ( err ) { @@ -82,7 +98,7 @@ export default function BlockGuidelinesPanel( { } finally { setIsLoading( false ); } - }; + } fetchBlockTypes(); }, [] ); @@ -186,12 +202,7 @@ export default function BlockGuidelinesPanel( { { getBlockTitle( blockName ) }

    - { data.guidelines.length > 100 - ? `${ data.guidelines.substring( - 0, - 100 - ) }...` - : data.guidelines } + { truncateText( data.guidelines, 100 ) }