From e647e1d819c453f37888bf58ab5526dce41f9ce4 Mon Sep 17 00:00:00 2001 From: Aagam Shah Date: Wed, 17 Jun 2026 09:35:56 +0530 Subject: [PATCH 1/4] Knowledge: Dissolve the Guidelines singleton into per-scope rows Replace the content-guidelines singleton + meta model with one `guideline`-typed `wp_knowledge` row per scope (content in post_content), addressed by a `guideline-` slug prefix. - Rename the `wp_knowledge_type` term `instruction` -> `guideline` (wp_knowledge_types(), TERM_GUIDELINE, and the upgrade migration, which now converges both `content` and the interim `instruction` onto `guideline`). - Add the `wp_guideline_scopes` registry filter and a read-only `/wp/v2/knowledge/guideline-scopes` controller; preload it on the Settings page. - Enforce identity with a reservation guard (force the guideline term, keep slugs unique with no suffix, reject duplicate creates) and a save filter (sanitize content + cap length, re-stamp scope titles in the site locale). - Delete both specialized controllers and the meta machinery; data flows through the standard /wp/v2/knowledge collection. - Drive the Settings UI through @wordpress/core-data (useEntityRecords + a runtime guidelineScope entity); delete the hand-rolled store/api. - Render sections from the registry, hide revision history, and keep the import/export JSON shape unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...erg-content-guidelines-rest-controller.php | 823 ----------------- ...ontent-guidelines-revisions-controller.php | 329 ------- ...nberg-guideline-scopes-rest-controller.php | 163 ++++ .../class-gutenberg-knowledge-post-type.php | 234 +---- lib/experimental/knowledge/index.php | 28 +- lib/experimental/knowledge/knowledge.php | 269 +++++- lib/experimental/knowledge/load.php | 34 + lib/upgrade.php | 31 +- ...ontent-guidelines-rest-controller-test.php | 854 ------------------ ...t-guidelines-revisions-controller-test.php | 50 - ...s-gutenberg-guideline-reservation-test.php | 159 ++++ ...nberg-guideline-scopes-controller-test.php | 97 ++ ...ass-gutenberg-knowledge-post-type-test.php | 4 +- .../knowledge/knowledge-migration-test.php | 19 + routes/guidelines/api.ts | 273 ------ .../components/block-guideline-modal.tsx | 86 +- .../components/block-guidelines.tsx | 79 +- .../components/guideline-accordion-form.tsx | 79 +- .../components/guideline-actions-section.tsx | 57 +- .../components/revision-history.tsx | 280 ------ routes/guidelines/data.ts | 209 +++++ routes/guidelines/entity.ts | 32 + routes/guidelines/import-export.ts | 181 ++++ routes/guidelines/package.json | 3 +- routes/guidelines/route.ts | 6 +- routes/guidelines/stage.tsx | 231 +++-- routes/guidelines/store.ts | 164 ---- routes/guidelines/types.ts | 69 +- test/e2e/specs/admin/guidelines.spec.js | 266 ++++-- tools/eslint/suppressions.json | 5 - 30 files changed, 1699 insertions(+), 3415 deletions(-) delete mode 100644 lib/experimental/knowledge/class-gutenberg-content-guidelines-rest-controller.php delete mode 100644 lib/experimental/knowledge/class-gutenberg-content-guidelines-revisions-controller.php create mode 100644 lib/experimental/knowledge/class-gutenberg-guideline-scopes-rest-controller.php delete mode 100644 phpunit/experimental/knowledge/class-gutenberg-content-guidelines-rest-controller-test.php delete mode 100644 phpunit/experimental/knowledge/class-gutenberg-content-guidelines-revisions-controller-test.php create mode 100644 phpunit/experimental/knowledge/class-gutenberg-guideline-reservation-test.php create mode 100644 phpunit/experimental/knowledge/class-gutenberg-guideline-scopes-controller-test.php delete mode 100644 routes/guidelines/api.ts delete mode 100644 routes/guidelines/components/revision-history.tsx create mode 100644 routes/guidelines/data.ts create mode 100644 routes/guidelines/entity.ts create mode 100644 routes/guidelines/import-export.ts delete mode 100644 routes/guidelines/store.ts diff --git a/lib/experimental/knowledge/class-gutenberg-content-guidelines-rest-controller.php b/lib/experimental/knowledge/class-gutenberg-content-guidelines-rest-controller.php deleted file mode 100644 index 528481ff7f66bc..00000000000000 --- a/lib/experimental/knowledge/class-gutenberg-content-guidelines-rest-controller.php +++ /dev/null @@ -1,823 +0,0 @@ -rest_base = self::REST_BASE; - } - - /** - * Resolves a post ID to a content-typed guideline post. - * - * Restricts /wp/v2/content-guidelines/{id} to posts tagged with the - * `guideline` term. Other knowledge types are addressable only via the - * standard /wp/v2/knowledge collection. - * - * @param int $id Post ID. - * @return WP_Post|WP_Error Post object on success, WP_Error on failure. - */ - protected function get_post( $id ) { - $post = parent::get_post( $id ); - if ( is_wp_error( $post ) ) { - return $post; - } - - if ( ! Gutenberg_Knowledge_Post_Type::is_content_guideline( $post->ID ) ) { - return new WP_Error( - 'rest_post_invalid_id', - __( 'Invalid post ID.', 'gutenberg' ), - array( 'status' => 404 ) - ); - } - - return $post; - } - - /** - * Registers the routes for the content guidelines singleton. - * - * Calls parent to register standard /{id} CRUD routes, then overrides the - * collection route with a singleton GET endpoint. - */ - public function register_routes() { - parent::register_routes(); - - // Override collection route with singleton GET + create. - register_rest_route( - $this->namespace, - '/' . $this->rest_base, - array( - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_guidelines' ), - 'permission_callback' => array( $this, 'get_guidelines_permissions_check' ), - 'args' => array( - 'category' => array( - 'description' => __( 'Limit response to a specific guideline category.', 'gutenberg' ), - 'type' => 'string', - 'enum' => Gutenberg_Knowledge_Post_Type::VALID_CATEGORIES, - 'sanitize_callback' => 'sanitize_text_field', - ), - 'block' => array( - 'description' => __( 'Limit response to guidelines for a specific block type.', 'gutenberg' ), - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - ), - 'status' => array( - 'description' => __( 'Limit response to guidelines with a specific status.', 'gutenberg' ), - 'type' => 'string', - 'enum' => Gutenberg_Knowledge_Post_Type::VALID_STATUSES, - 'sanitize_callback' => 'sanitize_text_field', - ), - ), - ), - 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' ), - ), - true - ); - } - - /** - * Retrieves the query params for the collection. - * - * Overridden to return empty since we use a singleton pattern, not a collection. - * - * @return array Empty collection parameters. - */ - public function get_collection_params() { - return array(); - } - - /** - * Checks if a given request has access to read the singleton 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_guidelines_permissions_check( WP_REST_Request $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - $post_type = get_post_type_object( $this->post_type ); - if ( ! current_user_can( $post_type->cap->read ) ) { - return new WP_Error( - 'rest_forbidden', - __( 'Sorry, you are not allowed to view the guidelines.', 'gutenberg' ), - array( 'status' => rest_authorization_required_code() ) - ); - } - - return true; - } - - /** - * Restricts guideline creation to administrators. - * - * Defers to the parent controller for per-post checks (status validation, - * sticky support, etc.) once the admin gate passes. - * - * @param WP_REST_Request $request Full details about the request. - * @return true|WP_Error True if the request has access, WP_Error object otherwise. - */ - public function create_item_permissions_check( $request ) { - if ( ! current_user_can( 'manage_options' ) ) { - return new WP_Error( - 'rest_cannot_create', - __( 'Sorry, you are not allowed to create guidelines.', 'gutenberg' ), - array( 'status' => rest_authorization_required_code() ) - ); - } - - return parent::create_item_permissions_check( $request ); - } - - /** - * Restricts guideline updates to administrators. - * - * @param WP_REST_Request $request Full details about the request. - * @return true|WP_Error True if the request has access, WP_Error object otherwise. - */ - public function update_item_permissions_check( $request ) { - if ( ! current_user_can( 'manage_options' ) ) { - return new WP_Error( - 'rest_cannot_edit', - __( 'Sorry, you are not allowed to edit guidelines.', 'gutenberg' ), - array( 'status' => rest_authorization_required_code() ) - ); - } - - return parent::update_item_permissions_check( $request ); - } - - /** - * Restricts guideline deletion to administrators. - * - * @param WP_REST_Request $request Full details about the request. - * @return true|WP_Error True if the request has access, WP_Error object otherwise. - */ - public function delete_item_permissions_check( $request ) { - if ( ! current_user_can( 'manage_options' ) ) { - return new WP_Error( - 'rest_cannot_delete', - __( 'Sorry, you are not allowed to delete guidelines.', 'gutenberg' ), - array( 'status' => rest_authorization_required_code() ) - ); - } - - return parent::delete_item_permissions_check( $request ); - } - - /** - * Gets the singleton guidelines. - * - * Supports query parameters: - * - ?status=publish|draft - Filter by status - * - ?category=copy|images|site|blocks|additional - Return only specific category - * - ?block=core/paragraph - Return only specific block's guidelines - * - * @param WP_REST_Request $request Full details about the request. - * @return WP_REST_Response Response object. - */ - public function get_guidelines( WP_REST_Request $request ) { - $status_filter = $request->get_param( 'status' ); - $post = $this->get_guidelines_post( $status_filter ); - - if ( ! $post ) { - $empty_status = $status_filter ? $status_filter : 'draft'; - return rest_ensure_response( - array( - 'id' => 0, - 'status' => $empty_status, - 'guideline_categories' => new stdClass(), - ) - ); - } - - return $this->prepare_item_for_response( $post, $request ); - } - - /** - * Creates the content guidelines singleton. - * - * Enforces the singleton constraint — only one post tagged with the - * `guideline` term may exist. - * - * @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 ) { - $existing = $this->get_guidelines_post(); - if ( $existing ) { - return new WP_Error( - 'rest_guidelines_exists', - __( 'Guidelines already exist. Use PATCH to update.', 'gutenberg' ), - array( 'status' => 400 ) - ); - } - - $guideline_term_id = self::get_or_create_term_id( - Gutenberg_Knowledge_Post_Type::TERM_GUIDELINE, - _x( 'Guideline', 'knowledge type', 'gutenberg' ) - ); - if ( is_wp_error( $guideline_term_id ) ) { - return $guideline_term_id; - } - - $prepared = $this->prepare_item_for_database( $request ); - $prepared->post_type = $this->post_type; - $prepared->post_title = __( 'Guidelines', 'gutenberg' ); - $prepared->tax_input = array( - Gutenberg_Knowledge_Post_Type::TAXONOMY => array( $guideline_term_id ), - ); - - if ( ! isset( $prepared->post_status ) ) { - $prepared->post_status = 'draft'; - } - - $post_id = wp_insert_post( wp_slash( (array) $prepared ), true ); - - if ( is_wp_error( $post_id ) ) { - return $post_id; - } - - if ( isset( $request['guideline_categories'] ) ) { - $categories = $this->sanitize_guideline_categories( $request['guideline_categories'] ); - $this->save_guideline_categories_to_meta( $post_id, $categories ); - } - - $post = get_post( $post_id ); - - $request->set_param( 'context', 'edit' ); - $response = $this->prepare_item_for_response( $post, $request ); - $response = rest_ensure_response( $response ); - $response->set_status( 201 ); - $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $post_id ) ) ); - - return $response; - } - - /** - * Updates the content guidelines singleton. - * - * Saves guideline categories to meta before updating the post so that - * the revision captures the updated meta values. - * - * @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 = $this->get_post( $request['id'] ); - if ( is_wp_error( $post ) ) { - return $post; - } - - // Save guideline categories to meta first (so revision captures them). - if ( isset( $request['guideline_categories'] ) ) { - $categories = $this->sanitize_guideline_categories( $request['guideline_categories'] ); - $this->save_guideline_categories_to_meta( $post->ID, $categories ); - } - - $prepared = $this->prepare_item_for_database( $request ); - $prepared->ID = $post->ID; - - // Trigger a post update to create a revision with the meta changes. - $result = wp_update_post( wp_slash( (array) $prepared ), true ); - - if ( is_wp_error( $result ) ) { - return $result; - } - - $post = get_post( $post->ID ); - - $request->set_param( 'context', 'edit' ); - - return $this->prepare_item_for_response( $post, $request ); - } - - /** - * Prepares a single guidelines post for database. - * - * Returns a stdClass with standard post fields. Guideline categories - * are handled separately via save_guideline_categories_to_meta(). - * - * @param WP_REST_Request $request Request object. - * @return stdClass Prepared post data. - */ - protected function prepare_item_for_database( $request ) { - $prepared = new stdClass(); - - if ( isset( $request['id'] ) ) { - $prepared->ID = $request['id']; - } - - if ( isset( $request['status'] ) ) { - $prepared->post_status = $request['status']; - } - - return $prepared; - } - - /** - * Prepares a single guidelines output for response. - * - * Builds the guideline_categories structured response from post meta - * and includes standard _links. - * - * @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 ) { - $fields = $this->get_fields_for_response( $request ); - $data = array(); - - if ( rest_is_field_included( 'id', $fields ) ) { - $data['id'] = $post->ID; - } - - if ( rest_is_field_included( 'status', $fields ) ) { - $data['status'] = $post->post_status; - } - - if ( rest_is_field_included( 'guideline_categories', $fields ) ) { - $guideline_categories = Gutenberg_Knowledge_Post_Type::get_guideline_categories_from_meta( $post->ID ); - - // Handle ?block filter. - $block_filter = $request->get_param( 'block' ); - if ( $block_filter && ! empty( $guideline_categories ) ) { - if ( isset( $guideline_categories['blocks'][ $block_filter ] ) ) { - $guideline_categories = array( - 'blocks' => array( - $block_filter => $guideline_categories['blocks'][ $block_filter ], - ), - ); - } else { - $guideline_categories = new stdClass(); - } - } elseif ( $request->get_param( 'category' ) ) { - // Handle ?category filter. - $category_filter = $request->get_param( 'category' ); - 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['guideline_categories'] = $guideline_categories; - } - - if ( rest_is_field_included( 'date', $fields ) ) { - $data['date'] = $this->prepare_date_response( $post->post_date_gmt, $post->post_date ); - } - - if ( rest_is_field_included( 'date_gmt', $fields ) ) { - $data['date_gmt'] = $this->prepare_date_response( $post->post_date_gmt ); - } - - if ( rest_is_field_included( 'modified', $fields ) ) { - $data['modified'] = $this->prepare_date_response( $post->post_modified_gmt, $post->post_modified ); - } - - if ( rest_is_field_included( 'modified_gmt', $fields ) ) { - $data['modified_gmt'] = $this->prepare_date_response( $post->post_modified_gmt ); - } - - if ( rest_is_field_included( 'author', $fields ) ) { - $data['author'] = (int) $post->post_author; - } - - $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; - $data = $this->add_additional_fields_to_object( $data, $request ); - $data = $this->filter_response_by_context( $data, $context ); - - $response = rest_ensure_response( $data ); - - if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { - $response->add_links( $this->prepare_links( $post->ID ) ); - } - - return $response; - } - - /** - * Prepares links for the request. - * - * Includes self, about, and version-history links. - * - * @param int $id Post ID. - * @return array Links for the given post. - */ - protected function prepare_links( $id ) { - $base = sprintf( '%s/%s', $this->namespace, $this->rest_base ); - - $links = array( - 'self' => array( - 'href' => rest_url( trailingslashit( $base ) . $id ), - ), - 'about' => array( - 'href' => rest_url( 'wp/v2/types/' . $this->post_type ), - ), - ); - - if ( post_type_supports( $this->post_type, 'revisions' ) ) { - $revisions = wp_get_latest_revision_id_and_total_count( $id ); - $revisions_count = ! is_wp_error( $revisions ) ? $revisions['count'] : 0; - $revisions_base = sprintf( '/%s/%d/revisions', $base, $id ); - - $links['version-history'] = array( - 'href' => rest_url( $revisions_base ), - 'count' => $revisions_count, - ); - } - - return $links; - } - - /** - * Saves guideline categories to post meta. - * - * @param int $post_id Post ID. - * @param array $categories Sanitized guideline categories. - */ - protected function save_guideline_categories_to_meta( int $post_id, array $categories ): void { - // Save standard categories. - foreach ( Gutenberg_Knowledge_Post_Type::CATEGORY_META_KEYS as $category ) { - if ( isset( $categories[ $category ] ) ) { - $meta_key = '_guideline_' . $category; - $value = $categories[ $category ]['guidelines'] ?? ''; - update_post_meta( $post_id, $meta_key, $value ); - } - } - - // Handle block-specific guidelines as individual meta keys. - if ( isset( $categories['blocks'] ) && is_array( $categories['blocks'] ) ) { - foreach ( $categories['blocks'] as $block_name => $block_data ) { - $meta_key = Gutenberg_Knowledge_Post_Type::block_name_to_meta_key( $block_name ); - $value = $block_data['guidelines'] ?? ''; - - if ( ! empty( $value ) ) { - update_post_meta( $post_id, $meta_key, $value ); - } else { - delete_post_meta( $post_id, $meta_key ); - } - } - } - } - - /** - * Sanitizes guideline categories data. - * - * @param mixed $categories Raw guideline categories from the request. - * @return array Sanitized guideline categories. - */ - protected function sanitize_guideline_categories( $categories ): array { - if ( ! is_array( $categories ) ) { - return array(); - } - - $valid_categories = Gutenberg_Knowledge_Post_Type::VALID_CATEGORIES; - $sanitized = array_intersect_key( $categories, array_flip( $valid_categories ) ); - - foreach ( $sanitized as $key => &$category ) { - if ( ! is_array( $category ) ) { - unset( $sanitized[ $key ] ); - continue; - } - - if ( 'blocks' === $key ) { - $category = $this->sanitize_blocks_category( $category ); - } else { - $category = $this->sanitize_standard_category( $category ); - } - } - unset( $category ); - - return $sanitized; - } - - /** - * Sanitizes a standard (non-blocks) guideline category. - * - * @param array $category Raw category data. - * @return array Sanitized category data. - */ - private function sanitize_standard_category( array $category ): array { - $sanitized = array_intersect_key( $category, array_flip( array( 'label', 'guidelines' ) ) ); - - foreach ( $sanitized as $key => &$value ) { - $value = is_string( $value ) ? sanitize_textarea_field( $value ) : ''; - $max = 'label' === $key ? self::MAX_LABEL_LENGTH : self::MAX_GUIDELINE_LENGTH; - if ( mb_strlen( $value, 'UTF-8' ) > $max ) { - $value = mb_substr( $value, 0, $max, 'UTF-8' ); - } - } - unset( $value ); - - return $sanitized; - } - - /** - * Sanitizes the blocks guideline category. - * - * @param array $blocks Raw blocks category data. - * @return array Sanitized blocks category data. - */ - private function sanitize_blocks_category( array $blocks ): array { - $sanitized = array(); - - foreach ( $blocks as $block_name => $block_data ) { - // Matches the block name validation in WP_Block_Type_Registry::register(). - if ( ! is_string( $block_name ) || ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $block_name ) ) { - continue; - } - - if ( ! is_array( $block_data ) ) { - continue; - } - - $sanitized_block = array_intersect_key( $block_data, array_flip( array( 'guidelines' ) ) ); - - if ( isset( $sanitized_block['guidelines'] ) ) { - $sanitized_block['guidelines'] = is_string( $sanitized_block['guidelines'] ) - ? sanitize_textarea_field( $sanitized_block['guidelines'] ) - : ''; - if ( mb_strlen( $sanitized_block['guidelines'], 'UTF-8' ) > self::MAX_GUIDELINE_LENGTH ) { - $sanitized_block['guidelines'] = mb_substr( $sanitized_block['guidelines'], 0, self::MAX_GUIDELINE_LENGTH, 'UTF-8' ); - } - } - - $sanitized[ $block_name ] = $sanitized_block; - } - - return $sanitized; - } - - /** - * Gets the single content guidelines post. - * - * @param string|null $status_filter Optional. Filter by status ('publish' or 'draft'). - * @return WP_Post|null The guidelines post or null if not found. - */ - protected function get_guidelines_post( ?string $status_filter = null ): ?WP_Post { - $post_status = array( 'publish', 'draft' ); - - if ( $status_filter ) { - $post_status = $status_filter; - } - - $posts = get_posts( - array( - 'post_type' => $this->post_type, - 'post_status' => $post_status, - 'posts_per_page' => 1, - 'orderby' => 'date', - 'order' => 'DESC', - 'no_found_rows' => true, - 'tax_query' => array( - array( - 'taxonomy' => Gutenberg_Knowledge_Post_Type::TAXONOMY, - 'field' => 'slug', - 'terms' => Gutenberg_Knowledge_Post_Type::TERM_GUIDELINE, - ), - ), - ) - ); - - 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 publish).', 'gutenberg' ), - 'type' => 'string', - 'enum' => Gutenberg_Knowledge_Post_Type::VALID_STATUSES, - 'context' => array( 'view', 'edit' ), - ), - 'guideline_categories' => array( - 'description' => __( 'The guideline categories and their content.', 'gutenberg' ), - 'type' => 'object', - 'context' => array( 'view', 'edit' ), - 'arg_options' => array( - 'validate_callback' => static function ( $value ) { - if ( ! is_array( $value ) && ! is_object( $value ) ) { - return new WP_Error( - 'rest_invalid_param', - __( 'guideline_categories must be a JSON object.', 'gutenberg' ), - array( 'status' => 400 ) - ); - } - return true; - }, - 'sanitize_callback' => static function ( $value ) { - return (array) $value; - }, - ), - 'properties' => array( - 'copy' => array( - 'type' => 'object', - 'properties' => array( - 'label' => array( - 'type' => 'string', - 'maxLength' => self::MAX_LABEL_LENGTH, - ), - 'guidelines' => array( - 'type' => 'string', - 'maxLength' => self::MAX_GUIDELINE_LENGTH, - ), - ), - ), - 'images' => array( - 'type' => 'object', - 'properties' => array( - 'label' => array( - 'type' => 'string', - 'maxLength' => self::MAX_LABEL_LENGTH, - ), - 'guidelines' => array( - 'type' => 'string', - 'maxLength' => self::MAX_GUIDELINE_LENGTH, - ), - ), - ), - 'site' => array( - 'type' => 'object', - 'properties' => array( - 'label' => array( - 'type' => 'string', - 'maxLength' => self::MAX_LABEL_LENGTH, - ), - 'guidelines' => array( - 'type' => 'string', - 'maxLength' => self::MAX_GUIDELINE_LENGTH, - ), - ), - ), - 'blocks' => array( - 'type' => 'object', - 'additionalProperties' => array( - 'type' => 'object', - 'properties' => array( - 'guidelines' => array( - 'type' => 'string', - 'maxLength' => self::MAX_GUIDELINE_LENGTH, - ), - ), - ), - ), - 'additional' => array( - 'type' => 'object', - 'properties' => array( - 'label' => array( - 'type' => 'string', - 'maxLength' => self::MAX_LABEL_LENGTH, - ), - 'guidelines' => array( - 'type' => 'string', - 'maxLength' => self::MAX_GUIDELINE_LENGTH, - ), - ), - ), - ), - ), - '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, - ), - ), - ); - - return $this->add_additional_fields_schema( $this->schema ); - } - - /** - * Resolve the `wp_knowledge_type` term by slug, creating it if missing. - * - * Used by the create flow to attach the freshly-inserted content guideline - * to the `guideline` term on first use, before the term is otherwise needed. - * - * @param string $slug Term slug. - * @param string $name Human-readable term name, used when creating. - * @return int|WP_Error Term ID on success, WP_Error on failure. - */ - private static function get_or_create_term_id( string $slug, string $name ) { - $term = get_term_by( 'slug', $slug, Gutenberg_Knowledge_Post_Type::TAXONOMY ); - if ( $term ) { - return (int) $term->term_id; - } - - $inserted = wp_insert_term( - $name, - Gutenberg_Knowledge_Post_Type::TAXONOMY, - array( 'slug' => $slug ) - ); - - if ( is_wp_error( $inserted ) ) { - return $inserted; - } - - return (int) $inserted['term_id']; - } -} diff --git a/lib/experimental/knowledge/class-gutenberg-content-guidelines-revisions-controller.php b/lib/experimental/knowledge/class-gutenberg-content-guidelines-revisions-controller.php deleted file mode 100644 index 98e76c206890a6..00000000000000 --- a/lib/experimental/knowledge/class-gutenberg-content-guidelines-revisions-controller.php +++ /dev/null @@ -1,329 +0,0 @@ -parent_post_type = Gutenberg_Knowledge_Post_Type::POST_TYPE; - $this->parent_base = Gutenberg_Content_Guidelines_REST_Controller::REST_BASE; - } - - /** - * Registers the routes for guideline revisions. - * - * Mirrors the route shape of WP_REST_Revisions_Controller::register_routes() - * but uses this controller's $parent_base so the same class can be mounted - * under multiple parent bases (e.g. /content-guidelines and /guidelines). - * The parent's $parent_base is private, so calling parent::register_routes() - * would always register under the post type's rest_base regardless of any - * override done here. - */ - public function register_routes() { - register_rest_route( - $this->namespace, - '/' . $this->parent_base . '/(?P[\d]+)/' . $this->rest_base, - array( - 'args' => array( - 'parent' => array( - 'description' => __( 'The ID for the parent of the revision.', 'gutenberg' ), - 'type' => 'integer', - ), - ), - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_items' ), - 'permission_callback' => array( $this, 'get_items_permissions_check' ), - 'args' => $this->get_collection_params(), - ), - 'schema' => array( $this, 'get_public_item_schema' ), - ) - ); - - register_rest_route( - $this->namespace, - '/' . $this->parent_base . '/(?P[\d]+)/' . $this->rest_base . '/(?P[\d]+)', - array( - 'args' => array( - 'parent' => array( - 'description' => __( 'The ID for the parent of the revision.', 'gutenberg' ), - 'type' => 'integer', - ), - 'id' => array( - 'description' => __( 'Unique identifier for the revision.', 'gutenberg' ), - 'type' => 'integer', - ), - ), - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_item' ), - 'permission_callback' => array( $this, 'get_item_permissions_check' ), - 'args' => array( - 'context' => $this->get_context_param( array( 'default' => 'view' ) ), - ), - ), - array( - 'methods' => WP_REST_Server::DELETABLE, - 'callback' => array( $this, 'delete_item' ), - 'permission_callback' => array( $this, 'delete_item_permissions_check' ), - 'args' => array( - 'force' => array( - 'type' => 'boolean', - 'default' => false, - 'description' => __( 'Required to be true, as revisions do not support trashing.', 'gutenberg' ), - ), - ), - ), - 'schema' => array( $this, 'get_public_item_schema' ), - ) - ); - - register_rest_route( - $this->namespace, - '/' . $this->parent_base . '/(?P[\d]+)/' . $this->rest_base . '/(?P[\d]+)/restore', - array( - array( - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'restore_revision' ), - 'permission_callback' => array( $this, 'restore_revision_permissions_check' ), - 'args' => array( - 'parent' => array( - 'description' => __( 'The ID for the parent of the revision.', 'gutenberg' ), - 'type' => 'integer', - ), - 'id' => array( - 'description' => __( 'Unique identifier for the revision to restore.', 'gutenberg' ), - 'type' => 'integer', - ), - ), - ), - ) - ); - } - - /** - * Resolves a parent post ID to a content-typed guideline post. - * - * Restricts /wp/v2/content-guidelines/{parent}/revisions to parents tagged - * with the `guideline` term. Revisions of other knowledge types are - * addressable only via the standard /wp/v2/knowledge collection. - * - * @param int $parent_post_id Supplied ID. - * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise. - */ - protected function get_parent( $parent_post_id ) { - $parent = parent::get_parent( $parent_post_id ); - if ( is_wp_error( $parent ) ) { - return $parent; - } - - if ( ! Gutenberg_Knowledge_Post_Type::is_content_guideline( $parent->ID ) ) { - return new WP_Error( - 'rest_post_invalid_parent', - __( 'Invalid post parent ID.', 'gutenberg' ), - array( 'status' => 404 ) - ); - } - - return $parent; - } - - /** - * Prepares the revision for the REST response. - * - * Adds guideline_categories from revision meta to the standard revision response. - * - * @param WP_Post $item Post revision object. - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response Response object. - */ - public function prepare_item_for_response( $item, $request ) { - $response = parent::prepare_item_for_response( $item, $request ); - - if ( is_wp_error( $response ) ) { - return $response; - } - - $fields = $this->get_fields_for_response( $request ); - - if ( rest_is_field_included( 'guideline_categories', $fields ) ) { - $data = $response->get_data(); - $guideline_categories = Gutenberg_Knowledge_Post_Type::get_guideline_categories_from_meta( $item->ID ); - $data['guideline_categories'] = ! empty( $guideline_categories ) ? $guideline_categories : new stdClass(); - $response->set_data( $data ); - } - - // Add embeddable author link to get author name in revision history screen - if ( ! empty( $item->post_author ) ) { - $response->add_link( - 'author', - rest_url( 'wp/v2/users/' . $item->post_author ), - array( 'embeddable' => true ) - ); - } - - return $response; - } - - /** - * Retrieves the revision's schema, conforming to JSON Schema. - * - * Adds guideline_categories to the standard revision schema. - * - * @return array Item schema data. - */ - public function get_item_schema() { - $schema = parent::get_item_schema(); - - $schema['properties']['guideline_categories'] = array( - 'description' => __( 'The guideline categories and their content.', 'gutenberg' ), - 'type' => 'object', - 'context' => array( 'view', 'edit' ), - 'readonly' => true, - ); - - return $schema; - } - - /** - * Restricts revision deletion to administrators. - * - * The inherited check only requires `delete_post` on the parent and the - * revision. The singleton route is admin-managed for every other write, - * so deleting revisions follows the same rule. - * - * @param WP_REST_Request $request Full details about the request. - * @return true|WP_Error True if the request has access, WP_Error object otherwise. - */ - public function delete_item_permissions_check( $request ) { - $parent = $this->get_parent( $request['parent'] ); - if ( is_wp_error( $parent ) ) { - return $parent; - } - - if ( ! current_user_can( 'manage_options' ) ) { - return new WP_Error( - 'rest_cannot_delete', - __( 'Sorry, you are not allowed to delete revisions.', 'gutenberg' ), - array( 'status' => rest_authorization_required_code() ) - ); - } - - return parent::delete_item_permissions_check( $request ); - } - - /** - * Checks if a given request has access to restore a revision. - * - * @param WP_REST_Request $request Full details about the request. - * @return true|WP_Error True if the request has access, WP_Error object otherwise. - */ - public function restore_revision_permissions_check( $request ) { - $parent = $this->get_parent( $request['parent'] ); - if ( is_wp_error( $parent ) ) { - return $parent; - } - - if ( ! current_user_can( 'manage_options' ) ) { - return new WP_Error( - 'rest_cannot_restore', - __( 'Sorry, you are not allowed to restore revisions.', 'gutenberg' ), - array( 'status' => rest_authorization_required_code() ) - ); - } - - return true; - } - - /** - * Restores a revision to the main guidelines post. - * - * Uses WordPress's native wp_restore_post_revision() which restores all - * revision fields, sets _edit_last meta, fires hooks, and 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 ) { - $parent = $this->get_parent( $request['parent'] ); - if ( is_wp_error( $parent ) ) { - return $parent; - } - - $revision = get_post( $request['id'] ); - if ( ! $revision || 'revision' !== $revision->post_type || (int) $revision->post_parent !== (int) $parent->ID ) { - return new WP_Error( - 'rest_revision_not_found', - __( 'Revision not found.', 'gutenberg' ), - array( 'status' => 404 ) - ); - } - - $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 ) - ); - } - - // Shape the restore response with the singleton controller so callers - // get the same payload as a regular GET /wp/v2/content-guidelines/{id}. - // The post type's registered REST controller is the standard - // WP_REST_Posts_Controller, which would return a different shape. - $post = get_post( $parent->ID ); - $singleton_controller = new Gutenberg_Content_Guidelines_REST_Controller(); - - return $singleton_controller->prepare_item_for_response( $post, $request ); - } -} diff --git a/lib/experimental/knowledge/class-gutenberg-guideline-scopes-rest-controller.php b/lib/experimental/knowledge/class-gutenberg-guideline-scopes-rest-controller.php new file mode 100644 index 00000000000000..e9c865db28f2cc --- /dev/null +++ b/lib/experimental/knowledge/class-gutenberg-guideline-scopes-rest-controller.php @@ -0,0 +1,163 @@ +namespace = 'wp/v2'; + $this->rest_base = 'knowledge/guideline-scopes'; + } + + /** + * Registers the routes for 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_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks whether the current user can read guideline scopes. + * + * Gated on the knowledge read capability, matching the data routes. + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error otherwise. + */ + public function get_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + if ( ! current_user_can( 'read_knowledge_items' ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'Sorry, you are not allowed to view guideline scopes.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Retrieves all registered guideline scopes. + * + * Labels are resolved at request time (in the request locale). + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response Response object. + */ + public function get_items( $request ) { + $data = array(); + + foreach ( wp_guideline_scopes() as $slug => $scope ) { + $item = $this->prepare_item_for_response( array_merge( array( 'slug' => $slug ), $scope ), $request ); + $data[] = $this->prepare_response_for_collection( $item ); + } + + return rest_ensure_response( $data ); + } + + /** + * Prepares a single scope for response. + * + * @param array $item Scope data with a `slug` key. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $item, $request ) { + $fields = $this->get_fields_for_response( $request ); + $data = array(); + + if ( rest_is_field_included( 'slug', $fields ) ) { + $data['slug'] = $item['slug']; + } + if ( rest_is_field_included( 'title', $fields ) ) { + $data['title'] = isset( $item['title'] ) ? $item['title'] : ''; + } + if ( rest_is_field_included( 'description', $fields ) ) { + $data['description'] = isset( $item['description'] ) ? $item['description'] : ''; + } + if ( rest_is_field_included( 'order', $fields ) ) { + $data['order'] = isset( $item['order'] ) ? (int) $item['order'] : 0; + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + return rest_ensure_response( $data ); + } + + /** + * Retrieves the scope 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' => 'guideline-scope', + 'type' => 'object', + 'properties' => array( + 'slug' => array( + 'description' => __( 'An alphanumeric identifier for the scope.', 'gutenberg' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'title' => array( + 'description' => __( 'The title for the scope.', 'gutenberg' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'description' => array( + 'description' => __( 'A human-readable description of the scope.', 'gutenberg' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'order' => array( + 'description' => __( 'The sort order of the scope on the Settings page.', 'gutenberg' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + ), + ); + + return $this->add_additional_fields_schema( $this->schema ); + } +} diff --git a/lib/experimental/knowledge/class-gutenberg-knowledge-post-type.php b/lib/experimental/knowledge/class-gutenberg-knowledge-post-type.php index 75462316277691..60474c79d376fb 100644 --- a/lib/experimental/knowledge/class-gutenberg-knowledge-post-type.php +++ b/lib/experimental/knowledge/class-gutenberg-knowledge-post-type.php @@ -29,59 +29,17 @@ class Gutenberg_Knowledge_Post_Type { const TAXONOMY = 'wp_knowledge_type'; /** - * Taxonomy term slug used for the site-wide guidelines singleton. + * Taxonomy term slug for the `guideline` knowledge type. * - * The site-wide guidelines post managed by the Settings → Guidelines page - * carries this term; it maps to the `guideline` built-in knowledge type. + * Guidelines are loaded by default when applicable; every row managed by + * the Settings → Guidelines page carries this term. Scope rows are further + * identified by the `guideline-` slug prefix (see the reservation guard in + * knowledge.php). * * @var string */ const TERM_GUIDELINE = 'guideline'; - /** - * The standard guideline category meta keys. - * - * @var array - */ - const CATEGORY_META_KEYS = array( - 'copy', - 'images', - 'site', - 'additional', - ); - - /** - * All valid guideline category keys for filtering. - * - * Includes standard categories plus 'blocks'. - * - * @var array - */ - const VALID_CATEGORIES = array( - 'copy', - 'images', - 'site', - 'additional', - 'blocks', - ); - - /** - * Valid guideline statuses. - * - * @var array - */ - const VALID_STATUSES = array( - 'draft', - 'publish', - ); - - /** - * Prefix for block-specific guideline meta keys. - * - * @var string - */ - const BLOCK_META_PREFIX = '_guideline_block_'; - /** * Register the custom post type. */ @@ -207,183 +165,11 @@ public static function register(): void { add_filter( 'user_has_cap', 'wp_maybe_grant_knowledge_caps', 1, 4 ); add_action( 'save_post_' . self::POST_TYPE, 'wp_knowledge_ensure_default_type_term' ); add_filter( 'wp_insert_term_data', 'wp_knowledge_maybe_map_term_label', 10, 2 ); - } - - /** - * Determines whether a knowledge post belongs to the site-wide - * guidelines singleton. - * - * Used by the /wp/v2/content-guidelines route to reject posts without - * the `guideline` term addressed by ID — those belong to the standard - * /wp/v2/knowledge collection. - * - * @param int $post_id Post ID. - * @return bool True if the post has the `guideline` term. - */ - public static function is_content_guideline( $post_id ) { - $terms = get_the_terms( $post_id, self::TAXONOMY ); - if ( is_wp_error( $terms ) || empty( $terms ) ) { - return false; - } - - foreach ( $terms as $term ) { - if ( self::TERM_GUIDELINE === $term->slug ) { - return true; - } - } - - return false; - } - - /** - * Register post meta fields with revision support. - */ - public static function register_post_meta(): void { - $meta_args = array( - 'show_in_rest' => true, - 'single' => true, - 'type' => 'string', - 'revisions_enabled' => true, - 'auth_callback' => function (): bool { - return current_user_can( 'manage_options' ); - }, - 'sanitize_callback' => 'sanitize_textarea_field', - ); - - // Register standard category meta. - foreach ( self::CATEGORY_META_KEYS as $category ) { - register_post_meta( self::POST_TYPE, '_guideline_' . $category, $meta_args ); - } - - // Register meta for content blocks. - foreach ( self::get_content_blocks() as $block_name ) { - register_post_meta( self::POST_TYPE, self::block_name_to_meta_key( $block_name ), $meta_args ); - } - } - - /** - * Get block names that have content role attributes. - * - * @return array Block names with content role. - */ - public static function get_content_blocks(): array { - $content_blocks = array(); - $registry = WP_Block_Type_Registry::get_instance(); - - foreach ( $registry->get_all_registered() as $block_type ) { - if ( self::block_has_content_role( $block_type ) ) { - $content_blocks[] = $block_type->name; - } - } - - return $content_blocks; - } - - /** - * Check if a block type has any attribute with content role. - * - * @param WP_Block_Type $block_type The block type to check. - * @return bool True if block has content role attribute. - */ - private static function block_has_content_role( WP_Block_Type $block_type ): bool { - if ( empty( $block_type->attributes ) ) { - return false; - } - - foreach ( $block_type->attributes as $attribute ) { - if ( isset( $attribute['role'] ) && 'content' === $attribute['role'] ) { - return true; - } - } - - return false; - } - - /** - * Convert a block name to a meta key. - * - * @param string $block_name The block name (e.g., 'core/paragraph'). - * @return string The meta key (e.g., '_guideline_block_core_paragraph'). - */ - public static function block_name_to_meta_key( string $block_name ): string { - // Replace '/' with '_' to create a valid meta key. - $sanitized = str_replace( '/', '_', $block_name ); - return self::BLOCK_META_PREFIX . $sanitized; - } - - /** - * Convert a meta key back to a block name. - * - * @param string $meta_key The meta key (e.g., '_guideline_block_core_paragraph'). - * @return string The block name (e.g., 'core/paragraph'). - */ - public static function meta_key_to_block_name( string $meta_key ): string { - // Remove prefix and convert first '_' back to '/'. - $without_prefix = str_replace( self::BLOCK_META_PREFIX, '', $meta_key ); - // Replace first underscore with '/' (namespace separator). - return preg_replace( '/_/', '/', $without_prefix, 1 ); - } - - /** - * Check if a meta key is a block guideline meta key. - * - * @param string $meta_key The meta key to check. - * @return bool True if it's a block guideline meta key. - */ - public static function is_block_meta_key( string $meta_key ): bool { - return strpos( $meta_key, self::BLOCK_META_PREFIX ) === 0; - } - - /** - * Gets guideline categories from post meta. - * - * Shared between the post controller and revisions controller. - * - * @param int $post_id Post ID (can be a post or revision ID). - * @return array Guideline categories. - */ - public static function get_guideline_categories_from_meta( int $post_id ): array { - $category_labels = array( - 'copy' => __( 'Copy Guidelines', 'gutenberg' ), - 'images' => __( 'Image Guidelines', 'gutenberg' ), - 'site' => __( 'Site Context', 'gutenberg' ), - 'additional' => __( 'Additional Guidelines', 'gutenberg' ), - ); - - $guideline_categories = array(); - - // Get standard categories. - foreach ( self::CATEGORY_META_KEYS as $category ) { - $meta_key = '_guideline_' . $category; - $value = get_post_meta( $post_id, $meta_key, true ); - - $guideline_categories[ $category ] = array( - 'label' => $category_labels[ $category ], - 'guidelines' => $value, - ); - } - - // Get block-specific guidelines from individual meta keys. - $all_meta = get_post_meta( $post_id ); - - $blocks = array(); - foreach ( $all_meta as $meta_key => $meta_values ) { - if ( self::is_block_meta_key( $meta_key ) ) { - $block_name = self::meta_key_to_block_name( $meta_key ); - $value = $meta_values[0] ?? ''; - - if ( ! empty( $value ) ) { - $blocks[ $block_name ] = array( - 'guidelines' => $value, - ); - } - } - } - - if ( ! empty( $blocks ) ) { - $guideline_categories['blocks'] = $blocks; - } - return $guideline_categories; + // Guideline-row reservation guard: keep `guideline-` slugs verbatim + // (no `-2` suffix) and enforce uniqueness, type, title, and content + // sanitization on the insert path (see knowledge.php). + add_filter( 'wp_unique_post_slug', 'wp_knowledge_preserve_guideline_slug', 10, 6 ); + add_filter( 'rest_pre_insert_' . self::POST_TYPE, 'wp_knowledge_guard_guideline_row', 10, 2 ); } } diff --git a/lib/experimental/knowledge/index.php b/lib/experimental/knowledge/index.php index 2364b9f093e596..4235d2900d5c0f 100644 --- a/lib/experimental/knowledge/index.php +++ b/lib/experimental/knowledge/index.php @@ -12,8 +12,7 @@ require_once __DIR__ . '/knowledge.php'; require_once __DIR__ . '/class-gutenberg-knowledge-post-type.php'; require_once __DIR__ . '/class-gutenberg-knowledge-rest-controller.php'; -require_once __DIR__ . '/class-gutenberg-content-guidelines-revisions-controller.php'; -require_once __DIR__ . '/class-gutenberg-content-guidelines-rest-controller.php'; +require_once __DIR__ . '/class-gutenberg-guideline-scopes-rest-controller.php'; /* * Register the knowledge post type. @@ -25,9 +24,8 @@ * Ensure the post type is registered before any other `rest_api_init` callback * runs. `init` normally fires before `rest_api_init`, but anything that calls * `rest_get_server()` early (e.g. from `plugins_loaded`) fires `rest_api_init` - * before `init` priority 10. The callbacks below — both `register_post_meta` - * and the controller instantiations — dereference the post type object and - * would fatal (or trip `_doing_it_wrong`) without this guard. + * before `init` priority 10. The callback below dereferences the post type + * object and would fatal (or trip `_doing_it_wrong`) without this guard. */ add_action( 'rest_api_init', @@ -39,23 +37,17 @@ static function () { 1 ); -// Register post meta once the REST API loads and the block registry is available. -add_action( 'rest_api_init', array( 'Gutenberg_Knowledge_Post_Type', 'register_post_meta' ) ); - /* - * Register content singleton routes beside the standard CPT routes. - * The singleton rule is scoped to /wp/v2/content-guidelines for UI handling. - * The standard /wp/v2/knowledge route keeps default post handling for every - * `wp_knowledge` post. If `guideline` becomes a data level singleton, add - * enforcement to the default CPT route too. + * Register the read-only guideline scopes registry route beside the standard + * CPT routes. Guideline data itself is read and written through the standard + * /wp/v2/knowledge collection; scope rows are identified by the `guideline-` + * slug prefix and the `guideline` knowledge type (see the reservation guard in + * knowledge.php). */ add_action( 'rest_api_init', static function () { - $content_controller = new Gutenberg_Content_Guidelines_REST_Controller(); - $content_controller->register_routes(); - - $content_revisions_controller = new Gutenberg_Content_Guidelines_Revisions_Controller(); - $content_revisions_controller->register_routes(); + $scopes_controller = new Gutenberg_Guideline_Scopes_REST_Controller(); + $scopes_controller->register_routes(); } ); diff --git a/lib/experimental/knowledge/knowledge.php b/lib/experimental/knowledge/knowledge.php index 0958295f385f9f..d14cef888f5d8a 100644 --- a/lib/experimental/knowledge/knowledge.php +++ b/lib/experimental/knowledge/knowledge.php @@ -9,6 +9,13 @@ exit; } +/** + * Maximum length, in characters, of a guideline row's content. + */ +if ( ! defined( 'GUTENBERG_GUIDELINE_MAX_LENGTH' ) ) { + define( 'GUTENBERG_GUIDELINE_MAX_LENGTH', 5000 ); +} + if ( ! function_exists( 'wp_knowledge_types' ) ) { /** * Returns the registered knowledge types keyed by slug. @@ -58,13 +65,105 @@ function wp_knowledge_types(): array { } } +if ( ! function_exists( 'wp_guideline_scopes' ) ) { + /** + * Returns the registered guideline scopes keyed by slug. + * + * Scopes are the sections shown on the Settings → Guidelines page. Each + * scope is backed by at most one `guideline`-typed `wp_knowledge` row whose + * slug is `guideline-{scope}`. Plugins can register their own scopes via the + * `wp_guideline_scopes` filter and the Settings page grows a section + * automatically. The registry carries identity and presentation only; rows + * are created on first save. + * + * @return array { + * Slug-keyed map of guideline scopes. + * + * @type array ...$0 { + * Data for a single scope. + * + * @type string $title Human-readable section title. + * @type string $description Human-readable section description. + * @type int $order Sort order on the Settings page. + * } + * } + * @phpstan-return array + */ + function wp_guideline_scopes(): array { + /** + * Filters the guideline scopes available on this site. + * + * @param array $scopes Slug-keyed map of guideline scopes. + */ + return apply_filters( + 'wp_guideline_scopes', + array( + 'site' => array( + 'title' => __( 'Site', 'gutenberg' ), + 'description' => __( "Describe your site's purpose, goals, and primary audience.", 'gutenberg' ), + 'order' => 10, + ), + 'copy' => array( + 'title' => __( 'Copy', 'gutenberg' ), + 'description' => __( 'Set your writing standards for tone, voice, style, and formatting.', 'gutenberg' ), + 'order' => 20, + ), + 'images' => array( + 'title' => __( 'Images', 'gutenberg' ), + 'description' => __( 'Outline your style, dimensions, formats, mood and aesthetic preferences.', 'gutenberg' ), + 'order' => 30, + ), + 'additional' => array( + 'title' => __( 'Additional', 'gutenberg' ), + 'description' => __( 'Add additional guidelines.', 'gutenberg' ), + 'order' => 50, + ), + ) + ); + } +} + +if ( ! function_exists( 'wp_knowledge_get_or_create_type_term' ) ) { + /** + * Resolve a `wp_knowledge_type` term by slug, creating it lazily. + * + * Created term names are written once in the site locale (via + * `wp_knowledge_maybe_map_term_label`) so they don't vary with whoever + * triggered creation. + * + * @access private + * + * @param string $slug Term slug. + * @return int|null Term ID, or null on failure. + */ + function wp_knowledge_get_or_create_type_term( string $slug ): ?int { + $term = term_exists( $slug, 'wp_knowledge_type' ); + if ( $term ) { + return (int) $term['term_id']; + } + + $switched = switch_to_locale( get_locale() ); + $term = wp_insert_term( $slug, 'wp_knowledge_type' ); + if ( $switched ) { + restore_previous_locale(); + } + + if ( is_wp_error( $term ) ) { + return null; + } + + return (int) $term['term_id']; + } +} + if ( ! function_exists( 'wp_knowledge_ensure_default_type_term' ) ) { /** * Hook callback for the `save_post_wp_knowledge` action that assigns the - * `note` fallback term when a knowledge post is saved without a type - * term. + * knowledge type term. * - * Uses `get_the_terms()` so the check is served by the object term cache. + * Rows whose slug begins with `guideline-` are forced onto the `guideline` + * type (the reservation rule: the prefix is reserved for guideline-typed + * rows). Any other row without a type term falls back to `note`. * * @access private * @@ -75,23 +174,28 @@ function wp_knowledge_ensure_default_type_term( int $post_id ): void { return; } - $terms = get_the_terms( $post_id, 'wp_knowledge_type' ); - if ( is_wp_error( $terms ) || ! empty( $terms ) ) { + $post = get_post( $post_id ); + if ( ! $post instanceof WP_Post ) { return; } - // Resolve to an ID up front (creating the term on first use): - // wp_set_object_terms() interprets strings as names for hierarchical - // taxonomies, not slugs. - $term = term_exists( 'note', 'wp_knowledge_type' ); - if ( ! $term ) { - $term = wp_insert_term( 'note', 'wp_knowledge_type' ); - if ( is_wp_error( $term ) ) { - return; + if ( 0 === strpos( $post->post_name, 'guideline-' ) ) { + $term_id = wp_knowledge_get_or_create_type_term( 'guideline' ); + if ( null !== $term_id ) { + wp_set_object_terms( $post_id, $term_id, 'wp_knowledge_type' ); } + return; + } + + $terms = get_the_terms( $post_id, 'wp_knowledge_type' ); + if ( is_wp_error( $terms ) || ! empty( $terms ) ) { + return; } - wp_set_object_terms( $post_id, (int) $term['term_id'], 'wp_knowledge_type' ); + $term_id = wp_knowledge_get_or_create_type_term( 'note' ); + if ( null !== $term_id ) { + wp_set_object_terms( $post_id, $term_id, 'wp_knowledge_type' ); + } } } @@ -218,3 +322,140 @@ function wp_knowledge_maybe_map_term_label( array $data, string $taxonomy ): arr return $data; } } + +if ( ! function_exists( 'wp_guideline_scope_from_slug' ) ) { + /** + * Resolve a registry scope key from a guideline row slug. + * + * Returns the scope key for `guideline-{scope}` slugs that match a + * registered scope, or null for block rows (`guideline-block-*`) and + * unknown scopes. + * + * @access private + * + * @param string $slug Post slug. + * @return string|null Scope key, or null if not a registry scope. + */ + function wp_guideline_scope_from_slug( string $slug ): ?string { + if ( 0 !== strpos( $slug, 'guideline-' ) || 0 === strpos( $slug, 'guideline-block-' ) ) { + return null; + } + + $scope = substr( $slug, strlen( 'guideline-' ) ); + $scopes = wp_guideline_scopes(); + + return isset( $scopes[ $scope ] ) ? $scope : null; + } +} + +if ( ! function_exists( 'wp_knowledge_preserve_guideline_slug' ) ) { + /** + * Hook callback for `wp_unique_post_slug` that keeps `guideline-` slugs + * verbatim. + * + * Slug is identity for guideline rows, so we never want WordPress to append + * a `-2` suffix. Uniqueness is enforced separately on the insert path by + * `wp_knowledge_guard_guideline_row()`. + * + * @access private + * + * @param string $slug The computed (possibly suffixed) slug. + * @param int $post_id Post ID. + * @param string $post_status Post status. + * @param string $post_type Post type. + * @param int $post_parent Post parent ID. + * @param string $original_slug The desired slug before uniqueness checks. + * @return string The slug to use. + */ + function wp_knowledge_preserve_guideline_slug( $slug, $post_id, $post_status, $post_type, $post_parent, $original_slug ) { + if ( 'wp_knowledge' === $post_type && 0 === strpos( (string) $original_slug, 'guideline-' ) ) { + return $original_slug; + } + + return $slug; + } +} + +if ( ! function_exists( 'wp_knowledge_guard_guideline_row' ) ) { + /** + * Hook callback for `rest_pre_insert_wp_knowledge` that enforces the + * guideline-row reservation rule and sanitization on the REST insert path. + * + * For rows whose slug begins with `guideline-`: + * - Rejects a slug already owned by a different row (one row per slug), on + * create and update alike. + * - Sanitizes `post_content` to plain text capped at the guideline length. + * - Re-stamps the title of registry scopes from `wp_guideline_scopes()` in + * the site locale. Block rows keep the client-provided canonical block name. + * + * @access private + * + * @param stdClass $prepared_post Prepared post object. + * @param WP_REST_Request $request Request object. + * @return stdClass|WP_Error Prepared post, or WP_Error on a slug conflict. + */ + function wp_knowledge_guard_guideline_row( $prepared_post, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $slug = ''; + if ( ! empty( $prepared_post->post_name ) ) { + $slug = $prepared_post->post_name; + } elseif ( ! empty( $prepared_post->ID ) ) { + $existing = get_post( $prepared_post->ID ); + if ( $existing instanceof WP_Post ) { + $slug = $existing->post_name; + } + } + + if ( 0 !== strpos( (string) $slug, 'guideline-' ) ) { + return $prepared_post; + } + + // Enforce one row per slug (the reservation rule). This runs on create + // and update alike: on update the row itself is excluded, so a + // content-only save passes, but repointing a row's slug onto a + // `guideline-` slug already owned by a different row is rejected. + $existing_ids = get_posts( + array( + 'post_type' => Gutenberg_Knowledge_Post_Type::POST_TYPE, + 'name' => $slug, + 'post_status' => 'any', + 'posts_per_page' => 1, + 'fields' => 'ids', + 'no_found_rows' => true, + 'post__not_in' => empty( $prepared_post->ID ) ? array() : array( (int) $prepared_post->ID ), + ) + ); + + if ( ! empty( $existing_ids ) ) { + return new WP_Error( + 'rest_knowledge_slug_exists', + __( 'A guideline with this slug already exists.', 'gutenberg' ), + array( 'status' => 409 ) + ); + } + + // Sanitize content: plain text, capped at the guideline length. + if ( isset( $prepared_post->post_content ) ) { + $content = sanitize_textarea_field( $prepared_post->post_content ); + if ( mb_strlen( $content, 'UTF-8' ) > GUTENBERG_GUIDELINE_MAX_LENGTH ) { + $content = mb_substr( $content, 0, GUTENBERG_GUIDELINE_MAX_LENGTH, 'UTF-8' ); + } + $prepared_post->post_content = $content; + } + + // Re-stamp registry scope titles in the site locale. Block rows keep the + // client-provided canonical block name. + $scope = wp_guideline_scope_from_slug( $slug ); + if ( null !== $scope ) { + $switched = switch_to_locale( get_locale() ); + $scopes = wp_guideline_scopes(); + if ( $switched ) { + restore_previous_locale(); + } + if ( isset( $scopes[ $scope ]['title'] ) ) { + $prepared_post->post_title = $scopes[ $scope ]['title']; + } + } + + return $prepared_post; + } +} diff --git a/lib/experimental/knowledge/load.php b/lib/experimental/knowledge/load.php index 5e9b46311bd809..b6605f729d612c 100644 --- a/lib/experimental/knowledge/load.php +++ b/lib/experimental/knowledge/load.php @@ -7,6 +7,7 @@ add_action( 'admin_menu', 'gutenberg_register_guidelines_settings_submenu', 10 ); add_action( 'admin_enqueue_scripts', 'gutenberg_guidelines_enqueue_block_registry_scripts', 5 ); +add_action( 'admin_enqueue_scripts', 'gutenberg_guidelines_preload_rest', 6 ); /** * Registers the Guidelines submenu item under Settings. @@ -41,3 +42,36 @@ function gutenberg_guidelines_enqueue_block_registry_scripts( $hook_suffix ) { wp_enqueue_script( 'wp-block-library' ); } + +/** + * Preloads the guideline scopes registry on the Guidelines admin page so the + * client renders its sections without an extra round trip. Mirrors the + * preloading the generated page template performs for site settings. + * + * @param string $hook_suffix The current admin page. + */ +function gutenberg_guidelines_preload_rest( $hook_suffix ) { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + if ( 'settings_page_guidelines-wp-admin' !== $hook_suffix ) { + return; + } + + $preload_paths = array( + '/wp/v2/knowledge/guideline-scopes', + array( '/wp/v2/knowledge/guideline-scopes', 'OPTIONS' ), + ); + + $preload_data = array_reduce( $preload_paths, 'rest_preload_api_request', array() ); + + wp_add_inline_script( + 'wp-api-fetch', + sprintf( + 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );', + wp_json_encode( $preload_data ) + ), + 'after' + ); +} diff --git a/lib/upgrade.php b/lib/upgrade.php index 98559d86635830..6d5ab02976bb18 100644 --- a/lib/upgrade.php +++ b/lib/upgrade.php @@ -90,12 +90,13 @@ function _gutenberg_migrate_enable_real_time_collaboration() { * Rename the experimental Guidelines storage to Knowledge: `wp_guideline` * posts become `wp_knowledge`, `wp_guideline_type` terms move to the * `wp_knowledge_type` taxonomy, and the built-in type terms are re-slugged - * (`content` becomes `guideline`, `artifact` becomes `note`). + * (`content` and the interim `instruction` both become `guideline`, and + * `artifact` becomes `note`). * * Runs regardless of whether the `gutenberg-guidelines` experiment is * currently enabled so rows created while it was previously on are migrated - * too. Revisions and `_guideline_*` post meta keep their parent linkage and - * names, so no further updates are needed. + * too. Post revisions keep their parent linkage and names, so no further + * updates are needed. * * @since 23.5.0 */ @@ -133,12 +134,17 @@ function _gutenberg_migrate_guidelines_to_knowledge() { * English title), so user-customized labels survive. */ $type_renames = array( - 'content' => array( + 'content' => array( 'slug' => 'guideline', 'old_labels' => array( 'content', 'Content' ), 'new_label' => _x( 'Guideline', 'knowledge type', 'gutenberg' ), ), - 'artifact' => array( + 'instruction' => array( + 'slug' => 'guideline', + 'old_labels' => array( 'instruction', 'Instruction' ), + 'new_label' => _x( 'Guideline', 'knowledge type', 'gutenberg' ), + ), + 'artifact' => array( 'slug' => 'note', 'old_labels' => array( 'artifact', 'Artifact' ), 'new_label' => _x( 'Note', 'knowledge type', 'gutenberg' ), @@ -159,6 +165,21 @@ function _gutenberg_migrate_guidelines_to_knowledge() { continue; } + // Don't collide with an already-correct term (e.g. both `content` and + // the interim `instruction` targeting `guideline`); the first one wins. + $target_exists = $wpdb->get_var( + $wpdb->prepare( + "SELECT t.term_id FROM {$wpdb->terms} t + INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = t.term_id + WHERE tt.taxonomy = %s AND t.slug = %s", + 'wp_knowledge_type', + $rename['slug'] + ) + ); + if ( $target_exists ) { + continue; + } + $update = array( 'slug' => $rename['slug'] ); if ( in_array( $term->name, $rename['old_labels'], true ) ) { $update['name'] = $rename['new_label']; diff --git a/phpunit/experimental/knowledge/class-gutenberg-content-guidelines-rest-controller-test.php b/phpunit/experimental/knowledge/class-gutenberg-content-guidelines-rest-controller-test.php deleted file mode 100644 index 3ae97cf2c75df9..00000000000000 --- a/phpunit/experimental/knowledge/class-gutenberg-content-guidelines-rest-controller-test.php +++ /dev/null @@ -1,854 +0,0 @@ -user->create( - array( 'role' => 'administrator' ) - ); - self::$editor_id = $factory->user->create( - array( 'role' => 'editor' ) - ); - } - - /** - * Clean up class fixtures. - */ - public static function wpTearDownAfterClass() { - self::delete_user( self::$admin_id ); - self::delete_user( self::$editor_id ); - } - - /** - * Helper: create a guidelines post via the REST API. - * - * @param array $args Optional request params to merge. - * @return WP_REST_Response - */ - private function create_guidelines( $args = array() ) { - wp_set_current_user( self::$admin_id ); - - $defaults = array( - 'status' => 'draft', - 'guideline_categories' => array( - 'copy' => array( - 'label' => 'Copy Guidelines', - 'guidelines' => 'Write clearly.', - ), - ), - ); - - $params = array_merge( $defaults, $args ); - $request = new WP_REST_Request( 'POST', self::REST_BASE ); - foreach ( $params as $key => $value ) { - $request->set_param( $key, $value ); - } - - return rest_get_server()->dispatch( $request ); - } - - /** - * Test that guidelines routes are registered. - * - * @covers ::register_routes - */ - public function test_register_routes() { - $routes = rest_get_server()->get_routes(); - - $this->assertArrayHasKey( self::REST_BASE, $routes, 'Collection route not registered.' ); - $this->assertArrayHasKey( self::REST_BASE . '/(?P[\d]+)', $routes, 'Single item route not registered.' ); - } - - /** - * Test that an admin can create a guidelines post. - * - * @covers ::create_item - */ - public function test_create_item() { - $response = $this->create_guidelines(); - - $this->assertSame( 201, $response->get_status() ); - - $data = $response->get_data(); - $this->assertArrayHasKey( 'id', $data ); - $this->assertSame( 'draft', $data['status'] ); - $this->assertArrayHasKey( 'guideline_categories', $data ); - $this->assertSame( 'Write clearly.', $data['guideline_categories']['copy']['guidelines'] ); - } - - /** - * Test that guidelines can be created with publish status. - * - * @covers ::create_item - */ - public function test_create_item_with_publish_status() { - $response = $this->create_guidelines( array( 'status' => 'publish' ) ); - - $this->assertSame( 201, $response->get_status() ); - $this->assertSame( 'publish', $response->get_data()['status'] ); - } - - /** - * Test that only one guidelines post can exist (singleton enforcement). - * - * @covers ::create_item - */ - public function test_create_item_singleton_enforcement() { - $this->create_guidelines(); - $response = $this->create_guidelines(); - - $this->assertErrorResponse( 'rest_guidelines_exists', $response, 400 ); - } - - /** - * Test that editors cannot create guidelines (requires manage_options). - * - * @covers ::create_item_permissions_check - */ - public function test_create_item_no_permission_editor() { - wp_set_current_user( self::$editor_id ); - - $request = new WP_REST_Request( 'POST', self::REST_BASE ); - $request->set_param( 'status', 'draft' ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertErrorResponse( 'rest_cannot_create', $response, 403 ); - } - - /** - * Test that GET returns the singleton guidelines with categories. - * - * @covers ::get_guidelines - */ - public function test_get_items() { - wp_set_current_user( self::$admin_id ); - $this->create_guidelines( - array( - 'status' => 'publish', - 'guideline_categories' => array( - 'copy' => array( 'guidelines' => 'Be concise.' ), - 'images' => array( 'guidelines' => 'Use alt text.' ), - ), - ) - ); - - $request = new WP_REST_Request( 'GET', self::REST_BASE ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); - $this->assertSame( 'publish', $data['status'] ); - $this->assertSame( 'Be concise.', $data['guideline_categories']['copy']['guidelines'] ); - $this->assertSame( 'Use alt text.', $data['guideline_categories']['images']['guidelines'] ); - } - - /** - * Test that GET returns an empty placeholder when no guidelines exist. - * - * @covers ::get_guidelines - */ - public function test_get_items_empty() { - wp_set_current_user( self::$admin_id ); - - $request = new WP_REST_Request( 'GET', self::REST_BASE ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); - $this->assertSame( 0, $data['id'] ); - $this->assertSame( 'draft', $data['status'] ); - } - - /** - * Test that the status query parameter filters guidelines correctly. - * - * @covers ::get_guidelines - */ - public function test_get_items_status_filter() { - wp_set_current_user( self::$admin_id ); - $this->create_guidelines( array( 'status' => 'draft' ) ); - - // Filter by publish should return empty since post is draft. - $request = new WP_REST_Request( 'GET', self::REST_BASE ); - $request->set_param( 'status', 'publish' ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertSame( 200, $response->get_status() ); - $this->assertSame( 0, $response->get_data()['id'] ); - - // Filter by draft should return the post. - $request = new WP_REST_Request( 'GET', self::REST_BASE ); - $request->set_param( 'status', 'draft' ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertSame( 200, $response->get_status() ); - $this->assertNotSame( 0, $response->get_data()['id'] ); - } - - /** - * Test that the category query parameter returns only the requested category. - * - * @covers ::get_guidelines - */ - public function test_get_items_category_filter() { - wp_set_current_user( self::$admin_id ); - $this->create_guidelines( - array( - 'guideline_categories' => array( - 'copy' => array( 'guidelines' => 'Copy text.' ), - 'images' => array( 'guidelines' => 'Images text.' ), - ), - ) - ); - - $request = new WP_REST_Request( 'GET', self::REST_BASE ); - $request->set_param( 'category', 'copy' ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - - $this->assertSame( 200, $response->get_status() ); - $this->assertArrayHasKey( 'copy', $data['guideline_categories'] ); - $this->assertArrayNotHasKey( 'images', $data['guideline_categories'] ); - } - - /** - * Test that the block query parameter returns only the matching block entry. - * - * @covers ::get_guidelines - */ - public function test_get_items_block_filter() { - wp_set_current_user( self::$admin_id ); - $this->create_guidelines( - array( - 'guideline_categories' => array( - 'copy' => array( 'guidelines' => 'Copy text.' ), - 'blocks' => array( - 'core/paragraph' => array( 'guidelines' => 'Paragraph guidelines.' ), - 'core/heading' => array( 'guidelines' => 'Heading guidelines.' ), - ), - ), - ) - ); - - $request = new WP_REST_Request( 'GET', self::REST_BASE ); - $request->set_param( 'block', 'core/paragraph' ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - - $this->assertSame( 200, $response->get_status() ); - $this->assertArrayHasKey( 'blocks', $data['guideline_categories'] ); - $this->assertArrayHasKey( 'core/paragraph', $data['guideline_categories']['blocks'] ); - $this->assertArrayNotHasKey( 'core/heading', $data['guideline_categories']['blocks'] ); - // Standard categories should not appear when filtering by block. - $this->assertArrayNotHasKey( 'copy', $data['guideline_categories'] ); - } - - /** - * Test that filtering by a non-existent block returns empty categories. - * - * @covers ::get_guidelines - */ - public function test_get_items_block_filter_nonexistent() { - wp_set_current_user( self::$admin_id ); - $this->create_guidelines(); - - $request = new WP_REST_Request( 'GET', self::REST_BASE ); - $request->set_param( 'block', 'core/nonexistent' ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - - $this->assertSame( 200, $response->get_status() ); - $this->assertEmpty( (array) $data['guideline_categories'] ); - } - - /** - * Test that unauthenticated users cannot read guidelines. - * - * @covers ::get_guidelines_permissions_check - */ - public function test_get_items_unauthenticated() { - wp_set_current_user( 0 ); - - $request = new WP_REST_Request( 'GET', self::REST_BASE ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertErrorResponse( 'rest_forbidden', $response, 401 ); - } - - /** - * Test that editors can read guidelines (edit_posts capability). - * - * @covers ::get_guidelines_permissions_check - */ - public function test_get_items_editor_can_read() { - wp_set_current_user( self::$admin_id ); - $this->create_guidelines( array( 'status' => 'publish' ) ); - - wp_set_current_user( self::$editor_id ); - - $request = new WP_REST_Request( 'GET', self::REST_BASE ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertSame( 200, $response->get_status() ); - } - - /** - * Test that GET with a valid ID returns the guidelines post. - * - * @covers ::get_item - */ - public function test_get_item() { - wp_set_current_user( self::$admin_id ); - $create_response = $this->create_guidelines(); - $post_id = $create_response->get_data()['id']; - - $request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . $post_id ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertSame( 200, $response->get_status() ); - $this->assertSame( $post_id, $response->get_data()['id'] ); - } - - /** - * Test that GET with an invalid ID returns 404. - * - * @covers ::get_item - */ - public function test_get_item_invalid_id() { - wp_set_current_user( self::$admin_id ); - - $request = new WP_REST_Request( 'GET', self::REST_BASE . '/99999' ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); - } - - /** - * Test that an admin can update guidelines and change status. - * - * @covers ::update_item - */ - public function test_update_item() { - wp_set_current_user( self::$admin_id ); - $create_response = $this->create_guidelines(); - $post_id = $create_response->get_data()['id']; - - $request = new WP_REST_Request( 'PATCH', self::REST_BASE . '/' . $post_id ); - $request->set_param( - 'guideline_categories', - array( - 'copy' => array( 'guidelines' => 'Updated copy.' ), - ) - ); - $request->set_param( 'status', 'publish' ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); - $this->assertSame( 'publish', $data['status'] ); - $this->assertSame( 'Updated copy.', $data['guideline_categories']['copy']['guidelines'] ); - } - - /** - * Test that editors cannot update guidelines. - * - * @covers ::update_item_permissions_check - */ - public function test_update_item_no_permission_editor() { - wp_set_current_user( self::$admin_id ); - $create_response = $this->create_guidelines(); - $post_id = $create_response->get_data()['id']; - - wp_set_current_user( self::$editor_id ); - - $request = new WP_REST_Request( 'PATCH', self::REST_BASE . '/' . $post_id ); - $request->set_param( 'status', 'publish' ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - /** - * Test that an admin can force-delete a guidelines post. - * - * @covers ::delete_item - */ - public function test_delete_item() { - wp_set_current_user( self::$admin_id ); - $create_response = $this->create_guidelines(); - $post_id = $create_response->get_data()['id']; - - $request = new WP_REST_Request( 'DELETE', self::REST_BASE . '/' . $post_id ); - $request->set_param( 'force', true ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertSame( 200, $response->get_status() ); - - // Confirm it's gone. - $request = new WP_REST_Request( 'GET', self::REST_BASE ); - $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 0, $response->get_data()['id'] ); - } - - /** - * Helper: insert a note-typed knowledge post directly, bypassing the singleton route. - * - * @return int Post ID. - */ - private function create_note_post() { - $note_term_id = self::factory()->term->create( - array( - 'taxonomy' => Gutenberg_Knowledge_Post_Type::TAXONOMY, - 'name' => 'Note', - 'slug' => 'note', - ) - ); - - $post_id = self::factory()->post->create( - array( - 'post_type' => Gutenberg_Knowledge_Post_Type::POST_TYPE, - 'post_status' => 'draft', - 'post_title' => 'Note', - ) - ); - - wp_set_object_terms( $post_id, array( $note_term_id ), Gutenberg_Knowledge_Post_Type::TAXONOMY ); - - return $post_id; - } - - /** - * Test that GET /content-guidelines/{id} rejects non-guideline-typed posts. - * - * @covers ::get_post - */ - public function test_get_item_rejects_note_post() { - wp_set_current_user( self::$admin_id ); - $note_post_id = $this->create_note_post(); - - $request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . $note_post_id ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); - } - - /** - * Test that PATCH /content-guidelines/{id} rejects non-guideline-typed posts. - * - * @covers ::get_post - */ - public function test_update_item_rejects_note_post() { - wp_set_current_user( self::$admin_id ); - $note_post_id = $this->create_note_post(); - - $request = new WP_REST_Request( 'PATCH', self::REST_BASE . '/' . $note_post_id ); - $request->set_param( 'status', 'publish' ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); - } - - /** - * Test that DELETE /content-guidelines/{id} rejects non-guideline-typed posts. - * - * @covers ::get_post - */ - public function test_delete_item_rejects_note_post() { - wp_set_current_user( self::$admin_id ); - $note_post_id = $this->create_note_post(); - - $request = new WP_REST_Request( 'DELETE', self::REST_BASE . '/' . $note_post_id ); - $request->set_param( 'force', true ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); - } - - /** - * Test that /content-guidelines/{id}/revisions rejects non-guideline-typed parents. - * - * @covers Gutenberg_Content_Guidelines_Revisions_Controller::get_parent - */ - public function test_revisions_reject_note_parent() { - wp_set_current_user( self::$admin_id ); - $note_post_id = $this->create_note_post(); - - $request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . $note_post_id . '/revisions' ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertErrorResponse( 'rest_post_invalid_parent', $response, 404 ); - } - - /** - * Test that editors cannot delete guidelines. - * - * @covers ::delete_item_permissions_check - */ - public function test_delete_item_no_permission_editor() { - wp_set_current_user( self::$admin_id ); - $create_response = $this->create_guidelines(); - $post_id = $create_response->get_data()['id']; - - wp_set_current_user( self::$editor_id ); - - $request = new WP_REST_Request( 'DELETE', self::REST_BASE . '/' . $post_id ); - $request->set_param( 'force', true ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertErrorResponse( 'rest_cannot_delete', $response, 403 ); - } - - /** - * Test that response includes self, about, and version-history links. - * - * @covers ::prepare_item_for_response - */ - public function test_response_includes_links() { - wp_set_current_user( self::$admin_id ); - $create_response = $this->create_guidelines(); - $post_id = $create_response->get_data()['id']; - - $request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . $post_id ); - $response = rest_get_server()->dispatch( $request ); - $links = $response->get_links(); - - $this->assertArrayHasKey( 'self', $links, 'Response missing self link.' ); - $this->assertArrayHasKey( 'about', $links, 'Response missing about link.' ); - $this->assertArrayHasKey( 'version-history', $links, 'Response missing version-history link.' ); - } - - /** - * Test that _fields parameter filters response to requested fields only. - * - * @covers ::prepare_item_for_response - */ - public function test_fields_parameter_filtering() { - wp_set_current_user( self::$admin_id ); - $create_response = $this->create_guidelines(); - $post_id = $create_response->get_data()['id']; - - $request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . $post_id ); - $request->set_param( '_fields', 'id,status' ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - - $this->assertArrayHasKey( 'id', $data ); - $this->assertArrayHasKey( 'status', $data ); - $this->assertArrayNotHasKey( 'guideline_categories', $data, '_fields should exclude unrequested fields.' ); - } - - /** - * Test that valid block names (namespace/block-name) are accepted. - * - * @covers ::prepare_item_for_database - */ - public function test_block_name_validation_valid() { - wp_set_current_user( self::$admin_id ); - $response = $this->create_guidelines( - array( - 'guideline_categories' => array( - 'blocks' => array( - 'core/paragraph' => array( 'guidelines' => 'Valid.' ), - 'my-plugin/my-block' => array( 'guidelines' => 'Also valid.' ), - '0-test/1-block' => array( 'guidelines' => 'Starts with digit.' ), - ), - ), - ) - ); - - $data = $response->get_data(); - $blocks = $data['guideline_categories']['blocks']; - $this->assertArrayHasKey( 'core/paragraph', $blocks ); - $this->assertArrayHasKey( 'my-plugin/my-block', $blocks ); - $this->assertArrayHasKey( '0-test/1-block', $blocks ); - } - - /** - * Test that invalid block names are stripped from the response. - * - * @covers ::prepare_item_for_database - */ - public function test_block_name_validation_invalid() { - wp_set_current_user( self::$admin_id ); - $response = $this->create_guidelines( - array( - 'guideline_categories' => array( - 'blocks' => array( - 'no-namespace' => array( 'guidelines' => 'Bad.' ), - 'UPPER/case' => array( 'guidelines' => 'Bad.' ), - 'has spaces/block' => array( 'guidelines' => 'Bad.' ), - 'core/paragraph' => array( 'guidelines' => 'Good.' ), - ), - ), - ) - ); - - $data = $response->get_data(); - $blocks = $data['guideline_categories']['blocks']; - $this->assertArrayHasKey( 'core/paragraph', $blocks ); - $this->assertArrayNotHasKey( 'no-namespace', $blocks ); - $this->assertArrayNotHasKey( 'UPPER/case', $blocks ); - $this->assertArrayNotHasKey( 'has spaces/block', $blocks ); - } - - /** - * Tests that updating guidelines creates revisions. - * - * @covers ::update_item - */ - public function test_revisions_created_on_update() { - wp_set_current_user( self::$admin_id ); - $create_response = $this->create_guidelines(); - $post_id = $create_response->get_data()['id']; - - $request = new WP_REST_Request( 'PATCH', self::REST_BASE . '/' . $post_id ); - $request->set_param( - 'guideline_categories', - array( 'copy' => array( 'guidelines' => 'Revised copy.' ) ) - ); - rest_get_server()->dispatch( $request ); - - $request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . $post_id . '/revisions' ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertSame( 200, $response->get_status() ); - $this->assertNotEmpty( $response->get_data() ); - } - - /** - * Tests that revision responses include guideline_categories. - * - * @covers ::prepare_item_for_response - */ - public function test_revision_includes_guideline_categories() { - wp_set_current_user( self::$admin_id ); - $create_response = $this->create_guidelines( - array( - 'guideline_categories' => array( - 'copy' => array( 'guidelines' => 'Original.' ), - ), - ) - ); - $post_id = $create_response->get_data()['id']; - - $request = new WP_REST_Request( 'PATCH', self::REST_BASE . '/' . $post_id ); - $request->set_param( - 'guideline_categories', - array( 'copy' => array( 'guidelines' => 'Updated.' ) ) - ); - rest_get_server()->dispatch( $request ); - - $request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . $post_id . '/revisions' ); - $response = rest_get_server()->dispatch( $request ); - $revisions = $response->get_data(); - - $this->assertArrayHasKey( 'guideline_categories', $revisions[0] ); - } - - /** - * Tests that restoring a revision works and returns parent post response. - * - * @covers Gutenberg_Content_Guidelines_Revisions_Controller::restore_revision - */ - public function test_restore_revision() { - wp_set_current_user( self::$admin_id ); - $create_response = $this->create_guidelines( - array( - 'guideline_categories' => array( - 'copy' => array( 'guidelines' => 'Version 1.' ), - ), - ) - ); - $post_id = $create_response->get_data()['id']; - - $request = new WP_REST_Request( 'PATCH', self::REST_BASE . '/' . $post_id ); - $request->set_param( - 'guideline_categories', - array( 'copy' => array( 'guidelines' => 'Version 2.' ) ) - ); - rest_get_server()->dispatch( $request ); - - $request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . $post_id . '/revisions' ); - $response = rest_get_server()->dispatch( $request ); - $revisions = $response->get_data(); - - $this->assertNotEmpty( $revisions ); - - // Restore the oldest revision. - $oldest_revision = end( $revisions ); - $request = new WP_REST_Request( - 'POST', - self::REST_BASE . '/' . $post_id . '/revisions/' . $oldest_revision['id'] . '/restore' - ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); - $this->assertSame( $post_id, $data['id'] ); - $this->assertArrayHasKey( 'guideline_categories', $data ); - } - - /** - * Tests that restore response includes _links (via prepare_item_for_response). - * - * @covers Gutenberg_Content_Guidelines_Revisions_Controller::restore_revision - */ - public function test_restore_revision_response_includes_links() { - wp_set_current_user( self::$admin_id ); - $create_response = $this->create_guidelines(); - $post_id = $create_response->get_data()['id']; - - $request = new WP_REST_Request( 'PATCH', self::REST_BASE . '/' . $post_id ); - $request->set_param( - 'guideline_categories', - array( 'copy' => array( 'guidelines' => 'Updated.' ) ) - ); - rest_get_server()->dispatch( $request ); - - $request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . $post_id . '/revisions' ); - $revisions = rest_get_server()->dispatch( $request )->get_data(); - - $request = new WP_REST_Request( - 'POST', - self::REST_BASE . '/' . $post_id . '/revisions/' . $revisions[0]['id'] . '/restore' - ); - $response = rest_get_server()->dispatch( $request ); - $links = $response->get_links(); - - $this->assertArrayHasKey( 'self', $links ); - $this->assertArrayHasKey( 'version-history', $links ); - } - - /** - * Editors cannot delete revisions on the singleton route. The inherited - * check would allow it via `delete_post`. The override locks it to - * administrators to match the rest of the singleton's write policy. - * - * @covers Gutenberg_Content_Guidelines_Revisions_Controller::delete_item_permissions_check - */ - public function test_delete_revision_no_permission_editor() { - wp_set_current_user( self::$admin_id ); - $create_response = $this->create_guidelines(); - $post_id = $create_response->get_data()['id']; - - $request = new WP_REST_Request( 'PATCH', self::REST_BASE . '/' . $post_id ); - $request->set_param( - 'guideline_categories', - array( 'copy' => array( 'guidelines' => 'Updated.' ) ) - ); - rest_get_server()->dispatch( $request ); - - $request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . $post_id . '/revisions' ); - $revisions = rest_get_server()->dispatch( $request )->get_data(); - - $this->assertNotEmpty( $revisions ); - - wp_set_current_user( self::$editor_id ); - - $request = new WP_REST_Request( - 'DELETE', - self::REST_BASE . '/' . $post_id . '/revisions/' . $revisions[0]['id'] - ); - $request->set_param( 'force', true ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertErrorResponse( 'rest_cannot_delete', $response, 403 ); - } - - /** - * Tests that restoring requires admin permissions. - * - * @covers Gutenberg_Content_Guidelines_Revisions_Controller::restore_revision_permissions_check - */ - public function test_restore_revision_no_permission() { - wp_set_current_user( self::$admin_id ); - $create_response = $this->create_guidelines(); - $post_id = $create_response->get_data()['id']; - - $request = new WP_REST_Request( 'PATCH', self::REST_BASE . '/' . $post_id ); - $request->set_param( - 'guideline_categories', - array( 'copy' => array( 'guidelines' => 'Updated.' ) ) - ); - rest_get_server()->dispatch( $request ); - - $request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . $post_id . '/revisions' ); - $response = rest_get_server()->dispatch( $request ); - $revisions = $response->get_data(); - - wp_set_current_user( self::$editor_id ); - - $request = new WP_REST_Request( - 'POST', - self::REST_BASE . '/' . $post_id . '/revisions/' . $revisions[0]['id'] . '/restore' - ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertErrorResponse( 'rest_cannot_restore', $response, 403 ); - } - - /** - * Test that the schema contains expected properties and status enum. - * - * @covers ::get_item_schema - */ - public function test_get_item_schema() { - $request = new WP_REST_Request( 'OPTIONS', self::REST_BASE ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - $schema = $data['schema']; - - $this->assertSame( 'content-guidelines', $schema['title'] ); - $this->assertArrayHasKey( 'id', $schema['properties'] ); - $this->assertArrayHasKey( 'status', $schema['properties'] ); - $this->assertArrayHasKey( 'guideline_categories', $schema['properties'] ); - $this->assertContains( 'draft', $schema['properties']['status']['enum'] ); - $this->assertContains( 'publish', $schema['properties']['status']['enum'] ); - } - - /** - * @doesNotPerformAssertions - */ - public function test_context_param() { - // Singleton endpoint does not use context param. - } - - /** - * @doesNotPerformAssertions - */ - public function test_prepare_item() { - // Covered by create and update tests. - } -} diff --git a/phpunit/experimental/knowledge/class-gutenberg-content-guidelines-revisions-controller-test.php b/phpunit/experimental/knowledge/class-gutenberg-content-guidelines-revisions-controller-test.php deleted file mode 100644 index 7cbcca334f77dd..00000000000000 --- a/phpunit/experimental/knowledge/class-gutenberg-content-guidelines-revisions-controller-test.php +++ /dev/null @@ -1,50 +0,0 @@ -assertFalse( - post_type_exists( Gutenberg_Knowledge_Post_Type::POST_TYPE ), - 'Pre-condition: post type must be unregistered to exercise the ordering bug.' - ); - - do_action( 'rest_api_init' ); - - $this->assertTrue( - post_type_exists( Gutenberg_Knowledge_Post_Type::POST_TYPE ), - 'The rest_api_init handler should defensively register the post type.' - ); - } -} diff --git a/phpunit/experimental/knowledge/class-gutenberg-guideline-reservation-test.php b/phpunit/experimental/knowledge/class-gutenberg-guideline-reservation-test.php new file mode 100644 index 00000000000000..f9a868f1006285 --- /dev/null +++ b/phpunit/experimental/knowledge/class-gutenberg-guideline-reservation-test.php @@ -0,0 +1,159 @@ +user->create( array( 'role' => 'administrator' ) ); + } + + /** + * Clean up class fixtures. + */ + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + } + + /** + * Helper: create a knowledge row via REST. + * + * @param array $body Request body params. + * @return WP_REST_Response Response object. + */ + private function create_row( array $body ): WP_REST_Response { + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge' ); + $request->set_body_params( $body ); + return rest_get_server()->dispatch( $request ); + } + + /** + * A row created with a `guideline-` slug is forced onto the `guideline` + * type and keeps its slug verbatim (no `-2` suffix). + */ + public function test_prefixed_slug_forces_guideline_term_and_keeps_slug() { + wp_set_current_user( self::$admin_id ); + + $response = $this->create_row( + array( + 'slug' => 'guideline-copy', + 'content' => 'Use active voice.', + 'status' => 'publish', + ) + ); + + $this->assertSame( 201, $response->get_status() ); + $id = $response->get_data()['id']; + + $this->assertSame( 'guideline-copy', get_post( $id )->post_name ); + + $terms = wp_get_object_terms( $id, 'wp_knowledge_type', array( 'fields' => 'slugs' ) ); + $this->assertSame( array( 'guideline' ), $terms ); + } + + /** + * A second create with an already-used `guideline-` slug is rejected. + */ + public function test_duplicate_prefixed_slug_rejected() { + wp_set_current_user( self::$admin_id ); + + $first = $this->create_row( + array( + 'slug' => 'guideline-site', + 'content' => 'First.', + 'status' => 'publish', + ) + ); + $this->assertSame( 201, $first->get_status() ); + + $second = $this->create_row( + array( + 'slug' => 'guideline-site', + 'content' => 'Second.', + 'status' => 'publish', + ) + ); + + $this->assertSame( 409, $second->get_status() ); + $this->assertSame( 'rest_knowledge_slug_exists', $second->get_data()['code'] ); + } + + /** + * Registry scope titles are re-stamped from `wp_guideline_scopes()`, + * ignoring any client-provided title. + */ + public function test_scope_title_restamped_from_registry() { + wp_set_current_user( self::$admin_id ); + + $response = $this->create_row( + array( + 'slug' => 'guideline-images', + 'title' => 'Bogus client title', + 'content' => 'Square images only.', + 'status' => 'publish', + ) + ); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'Images', get_post( $response->get_data()['id'] )->post_title ); + } + + /** + * Block rows keep the client-provided canonical block name as the title. + */ + public function test_block_row_keeps_canonical_title() { + wp_set_current_user( self::$admin_id ); + + $response = $this->create_row( + array( + 'slug' => 'guideline-block-core-paragraph', + 'title' => 'core/paragraph', + 'content' => 'Keep paragraphs short.', + 'status' => 'publish', + ) + ); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'core/paragraph', get_post( $response->get_data()['id'] )->post_title ); + } + + /** + * Content is sanitized to plain text and capped at the guideline length. + */ + public function test_content_sanitized_and_capped() { + wp_set_current_user( self::$admin_id ); + + $long = str_repeat( 'a', 6000 ); + + $response = $this->create_row( + array( + 'slug' => 'guideline-additional', + 'content' => '' . $long, + 'status' => 'publish', + ) + ); + + $this->assertSame( 201, $response->get_status() ); + $content = get_post( $response->get_data()['id'] )->post_content; + + $this->assertStringNotContainsString( 'assertLessThanOrEqual( 5000, mb_strlen( $content, 'UTF-8' ) ); + } +} diff --git a/phpunit/experimental/knowledge/class-gutenberg-guideline-scopes-controller-test.php b/phpunit/experimental/knowledge/class-gutenberg-guideline-scopes-controller-test.php new file mode 100644 index 00000000000000..07e5ae413c118e --- /dev/null +++ b/phpunit/experimental/knowledge/class-gutenberg-guideline-scopes-controller-test.php @@ -0,0 +1,97 @@ +user->create( array( 'role' => 'administrator' ) ); + } + + /** + * Clean up class fixtures. + */ + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + } + + /** + * The registry route is registered. + */ + public function test_route_registered() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/wp/v2/knowledge/guideline-scopes', $routes ); + } + + /** + * Reads require the knowledge read capability; logged-out users are denied. + */ + public function test_requires_read_knowledge() { + wp_set_current_user( 0 ); + + $response = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/wp/v2/knowledge/guideline-scopes' ) ); + + $this->assertSame( 401, $response->get_status() ); + } + + /** + * Administrators receive the default scopes keyed by slug with title, + * description, and order fields. + */ + public function test_returns_default_scopes_for_admin() { + wp_set_current_user( self::$admin_id ); + + $response = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/wp/v2/knowledge/guideline-scopes' ) ); + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $slugs = wp_list_pluck( $data, 'slug' ); + $this->assertSame( array( 'site', 'copy', 'images', 'additional' ), $slugs ); + + $this->assertArrayHasKey( 'title', $data[0] ); + $this->assertArrayHasKey( 'description', $data[0] ); + $this->assertArrayHasKey( 'order', $data[0] ); + $this->assertSame( 'Site', $data[0]['title'] ); + $this->assertIsInt( $data[0]['order'] ); + } + + /** + * The `wp_guideline_scopes` filter is reflected in the response, so plugins + * can register additional scopes. + */ + public function test_filter_adds_scope() { + wp_set_current_user( self::$admin_id ); + + $callback = static function ( $scopes ) { + $scopes['custom'] = array( + 'title' => 'Custom', + 'description' => 'Custom scope.', + 'order' => 99, + ); + return $scopes; + }; + add_filter( 'wp_guideline_scopes', $callback ); + + $data = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/wp/v2/knowledge/guideline-scopes' ) )->get_data(); + $slugs = wp_list_pluck( $data, 'slug' ); + + remove_filter( 'wp_guideline_scopes', $callback ); + + $this->assertContains( 'custom', $slugs ); + } +} diff --git a/phpunit/experimental/knowledge/class-gutenberg-knowledge-post-type-test.php b/phpunit/experimental/knowledge/class-gutenberg-knowledge-post-type-test.php index 4c2d9960b3011c..61c512d023f927 100644 --- a/phpunit/experimental/knowledge/class-gutenberg-knowledge-post-type-test.php +++ b/phpunit/experimental/knowledge/class-gutenberg-knowledge-post-type-test.php @@ -135,7 +135,7 @@ public function test_save_post_preserves_explicit_term() { array( 'post_type' => Gutenberg_Knowledge_Post_Type::POST_TYPE, 'post_status' => 'draft', - 'post_title' => 'Guideline-typed knowledge post', + 'post_title' => 'Typed knowledge post', 'tax_input' => array( Gutenberg_Knowledge_Post_Type::TAXONOMY => array( $guideline_term_id ), ), @@ -202,7 +202,7 @@ public function test_save_post_preserves_term_on_update() { array( 'post_type' => Gutenberg_Knowledge_Post_Type::POST_TYPE, 'post_status' => 'draft', - 'post_title' => 'Guideline-typed knowledge post', + 'post_title' => 'Typed knowledge post', 'tax_input' => array( Gutenberg_Knowledge_Post_Type::TAXONOMY => array( $guideline_term_id ), ), diff --git a/phpunit/experimental/knowledge/knowledge-migration-test.php b/phpunit/experimental/knowledge/knowledge-migration-test.php index 7c4705ca6aba34..9d64133ced83b0 100644 --- a/phpunit/experimental/knowledge/knowledge-migration-test.php +++ b/phpunit/experimental/knowledge/knowledge-migration-test.php @@ -48,6 +48,25 @@ public function test_migrates_legacy_guideline_rows() { $this->assertSame( array( 'guideline' ), $slugs ); } + /** + * A site already on the interim `instruction` slug converges to + * `guideline`, and the default label is refreshed. + */ + public function test_reslugs_interim_instruction_term() { + $term = wp_insert_term( + 'Instruction', + 'wp_knowledge_type', + array( 'slug' => 'instruction' ) + ); + $this->assertIsArray( $term, 'Pre-condition: term must insert cleanly.' ); + + _gutenberg_migrate_guidelines_to_knowledge(); + + $migrated_term = get_term( $term['term_id'] ); + $this->assertSame( 'guideline', $migrated_term->slug ); + $this->assertSame( 'Guideline', $migrated_term->name ); + } + /** * Built-in type terms already living in `wp_knowledge_type` are * re-slugged too, and a user-customized term name is preserved while diff --git a/routes/guidelines/api.ts b/routes/guidelines/api.ts deleted file mode 100644 index 9351c338795949..00000000000000 --- a/routes/guidelines/api.ts +++ /dev/null @@ -1,273 +0,0 @@ -/** - * WordPress dependencies - */ -import apiFetch from '@wordpress/api-fetch'; -import { downloadBlob } from '@wordpress/blob'; -import { dispatch, select } from '@wordpress/data'; -import { __ } from '@wordpress/i18n'; -import { store as noticesStore } from '@wordpress/notices'; - -/** - * Internal dependencies - */ -import { store as coreGuidelinesStore } from './store'; -import type { - RestGuidelinesResponse, - GuidelinesImportData, - GuidelinesRevision, - Categories, -} from './types'; - -const FLAT_CATEGORIES = [ 'site', 'copy', 'images', 'additional' ] as const; - -function isValidGuidelinesImport( - data: unknown -): data is GuidelinesImportData { - return ( - !! data && - typeof data === 'object' && - 'guideline_categories' in data && - typeof ( data as Record< string, unknown > ).guideline_categories === - 'object' && - ( data as Record< string, unknown > ).guideline_categories !== null - ); -} - -export async function fetchGuidelines(): Promise< RestGuidelinesResponse > { - const { setFromResponse } = dispatch( coreGuidelinesStore ); - - const response = ( await apiFetch( { - path: '/wp/v2/content-guidelines?context=edit', - } ) ) as RestGuidelinesResponse; - - setFromResponse( response ); - - return response; -} - -export async function saveGuidelines(): Promise< RestGuidelinesResponse > { - const { setFromResponse } = dispatch( coreGuidelinesStore ); - - const guidelinesStore = select( coreGuidelinesStore ); - - const id = guidelinesStore.getId(); - const status = guidelinesStore.getStatus() || 'draft'; - const categories = guidelinesStore.getAllGuidelines(); - - const response = await saveGuidelinesBypassingStore( - id, - status, - categories - ); - - setFromResponse( response ); - - return response; -} - -async function saveGuidelinesBypassingStore( - id: number | null, - status: string, - categories: Categories -): Promise< RestGuidelinesResponse > { - const data = { - id, - status, - guideline_categories: { - site: { - guidelines: categories.site, - }, - copy: { - guidelines: categories.copy, - }, - images: { - guidelines: categories.images, - }, - additional: { - guidelines: categories.additional, - }, - blocks: Object.fromEntries( - Object.entries( categories.blocks ).map( - ( [ blockName, guidelines ] ) => [ - blockName, - { guidelines }, - ] - ) - ), - }, - }; - - const path = id - ? `/wp/v2/content-guidelines/${ id }` - : '/wp/v2/content-guidelines'; - const method = id ? 'PUT' : 'POST'; - - const response = ( await apiFetch( { - path, - method, - data, - } ) ) as RestGuidelinesResponse; - - return response; -} - -/** - * Opens file selector, reads the selected file and imports the guidelines. - * @param file Guidelines JSON file - */ -export async function importGuidelines( file: File ): Promise< void > { - const { setFromResponse } = dispatch( coreGuidelinesStore ); - const guidelinesStore = select( coreGuidelinesStore ); - const { createSuccessNotice } = dispatch( noticesStore ); - - const parsed: unknown = JSON.parse( await file.text() ); - - if ( ! isValidGuidelinesImport( parsed ) ) { - throw new Error( - __( - 'Check that your file contains valid JSON markup and try again.' - ) - ); - } - - const existingGuidelines = guidelinesStore.getAllGuidelines(); - - const newGuidelines = { - /** - * Set empty string to all the simple guidelines so that if the category is not present - * in the parsed data, the category gets removed. - */ - ...Object.fromEntries( - FLAT_CATEGORIES.map( ( category ) => [ category, '' ] ) - ), - /** - * Set empty string to all the existing block guidelines so that if the block is not present - * in the parsed data, the block gets removed. - */ - blocks: Object.fromEntries( - Object.keys( existingGuidelines.blocks ).map( ( block ) => [ - block, - '', - ] ) - ), - } as Categories; - - // Now let's populate the simple guidelines with the parsed data. - for ( const cat of FLAT_CATEGORIES ) { - const guidelines = parsed.guideline_categories[ cat ]?.guidelines || ''; - newGuidelines[ cat ] = guidelines; - } - - // Now let's populate the block guidelines with the parsed data. - const parsedBlocks = parsed.guideline_categories?.blocks ?? {}; - for ( const [ key, value ] of Object.entries( parsedBlocks ) ) { - newGuidelines.blocks[ key ] = value?.guidelines || ''; - } - - const response = await saveGuidelinesBypassingStore( - guidelinesStore.getId(), - guidelinesStore.getStatus() || 'draft', - newGuidelines - ); - - setFromResponse( response ); - createSuccessNotice( __( 'Guidelines imported.' ), { - type: 'snackbar', - } ); -} - -/** - * Exports the guidelines as a JSON file. - */ -export function exportGuidelines(): void { - const { createSuccessNotice } = dispatch( noticesStore ); - - const guidelinesStore = select( coreGuidelinesStore ); - const guidelinesCategories = guidelinesStore.getAllGuidelines(); - const blockGuidelines = guidelinesStore.getBlockGuidelines(); - - const data = { - guideline_categories: { - ...Object.fromEntries( - FLAT_CATEGORIES.map( ( guidelineCategory ) => [ - guidelineCategory, - { - guidelines: - guidelinesCategories[ guidelineCategory ] ?? '', - }, - ] ) - ), - blocks: Object.fromEntries( - Object.entries( blockGuidelines ).map( - ( [ blockName, guidelines ] ) => [ - blockName, - { guidelines }, - ] - ) - ), - }, - }; - - const now = new Date(); - const exportDate = [ - now.getFullYear(), - String( now.getMonth() + 1 ).padStart( 2, '0' ), - String( now.getDate() ).padStart( 2, '0' ), - ].join( '-' ); - downloadBlob( - `guidelines-${ exportDate }.json`, - JSON.stringify( data, null, 2 ), - 'application/json' - ); - - createSuccessNotice( __( 'Guidelines exported.' ), { - type: 'snackbar', - } ); -} - -export async function fetchGuidelinesRevisions( { - guidelinesId, - page = 1, - perPage = 10, - search, -}: { - guidelinesId: number; - page?: number; - perPage?: number; - search?: string; -} ): Promise< { - revisions: GuidelinesRevision[]; - total: number; - totalPages: number; -} > { - const params = new URLSearchParams( { - page: String( page ), - per_page: String( perPage ), - _embed: 'author', - ...( search ? { search } : {} ), - } ); - - const response = ( await apiFetch( { - path: `/wp/v2/content-guidelines/${ guidelinesId }/revisions?${ params }`, - parse: false, - } ) ) as Response; - - const revisions = ( await response.json() ) as GuidelinesRevision[]; - const total = parseInt( response.headers.get( 'X-WP-Total' ) ?? '0', 10 ); - const totalPages = parseInt( - response.headers.get( 'X-WP-TotalPages' ) ?? '0', - 10 - ); - - return { revisions, total, totalPages }; -} - -export async function restoreGuidelinesRevision( - guidelinesId: number, - revisionId: number -): Promise< RestGuidelinesResponse > { - return ( await apiFetch( { - path: `/wp/v2/content-guidelines/${ guidelinesId }/revisions/${ revisionId }/restore`, - method: 'POST', - } ) ) as RestGuidelinesResponse; -} diff --git a/routes/guidelines/components/block-guideline-modal.tsx b/routes/guidelines/components/block-guideline-modal.tsx index ef0dbb2cdf09f0..4afcae78b8b17f 100644 --- a/routes/guidelines/components/block-guideline-modal.tsx +++ b/routes/guidelines/components/block-guideline-modal.tsx @@ -14,31 +14,30 @@ import { } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { useMemo, useState } from '@wordpress/element'; -import { useDispatch, useSelect } from '@wordpress/data'; -import { - privateApis as blocksPrivateApis, - store as blocksStore, -} from '@wordpress/blocks'; +import { useDispatch } from '@wordpress/data'; import { store as noticesStore } from '@wordpress/notices'; -import { unlock } from '@wordpress/routes-lock-unlock'; /** * Internal dependencies */ -import { saveGuidelines } from '../api'; -import { store as coreGuidelinesStore } from '../store'; +import { blockSlug, saveGuidelineRow, deleteGuidelineRow } from '../data'; +import type { ContentBlock, GuidelineRow, GuidelineQuery } from '../types'; import './block-guideline-modal.scss'; -const { isContentBlock } = unlock( blocksPrivateApis ); - interface BlockGuidelineModalProps { closeModal: () => void; initialBlock?: string; + contentBlocks: ContentBlock[]; + bySlug: Record< string, GuidelineRow >; + query: GuidelineQuery; } export default function BlockGuidelineModal( { closeModal, initialBlock, + contentBlocks, + bySlug, + query, }: BlockGuidelineModalProps ) { const [ selectedBlock, setSelectedBlock ] = useState< string | undefined >( initialBlock @@ -49,50 +48,34 @@ export default function BlockGuidelineModal( { const [ showRemoveConfirmation, setShowRemoveConfirmation ] = useState( false ); - const blockGuidelines = useSelect( - ( select ) => select( coreGuidelinesStore ).getBlockGuidelines(), - [] - ); - const isEditing = !! initialBlock; - const currentGuideline = blockGuidelines[ selectedBlock ] ?? ''; + const currentGuideline = selectedBlock + ? bySlug[ blockSlug( selectedBlock ) ]?.content ?? '' + : ''; const [ guidelineText, setGuidelineText ] = useState( currentGuideline ); - const blockOptions = useSelect( - // @ts-ignore - ( select ) => select( blocksStore ).getBlockTypes(), - [] - ); - const availableBlockOptions = useMemo( () => { - const set = new Set( Object.keys( blockGuidelines ) ); - if ( initialBlock ) { - set.delete( initialBlock ); - } - if ( selectedBlock ) { - set.delete( selectedBlock ); - } - - return blockOptions + return contentBlocks .filter( ( block ) => - isContentBlock( block.name ) && ! set.has( block.name ) + ! bySlug[ blockSlug( block.name ) ] || + block.name === selectedBlock ) + .filter( ( block ) => block.name !== initialBlock ) .map( ( block ) => ( { value: block.name, label: block.title, } ) ); - }, [ blockGuidelines, blockOptions, initialBlock, selectedBlock ] ); + }, [ contentBlocks, bySlug, initialBlock, selectedBlock ] ); const selectedBlockLabel = useMemo( () => - blockOptions.find( ( block ) => block.name === selectedBlock ) + contentBlocks.find( ( block ) => block.name === selectedBlock ) ?.title || '', - [ blockOptions, selectedBlock ] + [ contentBlocks, selectedBlock ] ); - const { setBlockGuideline } = useDispatch( coreGuidelinesStore ); const { createSuccessNotice } = useDispatch( noticesStore ); const handleSave = ( value: string ) => { @@ -102,9 +85,25 @@ export default function BlockGuidelineModal( { } setIsSaving( true ); - const oldValue = blockGuidelines[ selectedBlock ]; - setBlockGuideline( selectedBlock, value ); - saveGuidelines() + const slug = blockSlug( selectedBlock ); + const existingId = bySlug[ slug ]?.id; + + let operation: Promise< void >; + if ( value ) { + operation = saveGuidelineRow( + slug, + selectedBlock, + value, + existingId, + query + ); + } else if ( existingId ) { + operation = deleteGuidelineRow( existingId ); + } else { + operation = Promise.resolve(); + } + + operation .then( () => { setError( null ); createSuccessNotice( @@ -115,10 +114,7 @@ export default function BlockGuidelineModal( { ); closeModal(); } ) - .catch( ( e: Error ) => { - setError( e.message ); - setBlockGuideline( selectedBlock, oldValue ); - } ) + .catch( ( e: Error ) => setError( e.message ) ) .finally( () => setIsSaving( false ) ); }; @@ -211,8 +207,6 @@ export default function BlockGuidelineModal( { title={ __( 'Remove block guidelines' ) } __experimentalHideHeader={ false } onConfirm={ () => { - // We need to pass an empty string to remove the guideline. - // This is because the API will only remove the guideline if the value is an empty string. handleSave( '' ); setShowRemoveConfirmation( false ); } } @@ -224,7 +218,7 @@ export default function BlockGuidelineModal( { { sprintf( /* translators: %s: Block name. */ __( - 'You are about to remove the block guidelines for the %s block. This can be undone from revision history.' + 'You are about to remove the block guidelines for the %s block.' ), selectedBlockLabel ) } diff --git a/routes/guidelines/components/block-guidelines.tsx b/routes/guidelines/components/block-guidelines.tsx index 2723c7cd011fe6..b68d39d02e75a6 100644 --- a/routes/guidelines/components/block-guidelines.tsx +++ b/routes/guidelines/components/block-guidelines.tsx @@ -16,17 +16,16 @@ import { } from '@wordpress/dataviews'; import { __, sprintf } from '@wordpress/i18n'; import { useEffect, useMemo, useState } from '@wordpress/element'; -import { useSelect, useDispatch } from '@wordpress/data'; +import { useDispatch } from '@wordpress/data'; import { blockDefault } from '@wordpress/icons'; -import { store as blocksStore } from '@wordpress/blocks'; import { store as noticesStore } from '@wordpress/notices'; /** * Internal dependencies */ import BlockGuidelineModal from './block-guideline-modal'; -import { saveGuidelines } from '../api'; -import { store as coreGuidelinesStore } from '../store'; +import { blockSlug, deleteGuidelineRow } from '../data'; +import type { ContentBlock, GuidelineRow, GuidelineQuery } from '../types'; import './block-guidelines.scss'; const PER_PAGE = 5; @@ -71,7 +70,17 @@ const fields = [ }, ]; -export default function BlockGuidelines() { +interface BlockGuidelinesProps { + contentBlocks: ContentBlock[]; + bySlug: Record< string, GuidelineRow >; + query: GuidelineQuery; +} + +export default function BlockGuidelines( { + contentBlocks, + bySlug, + query, +}: BlockGuidelinesProps ) { const [ isOpen, setIsOpen ] = useState( false ); const [ view, setView ] = useState< View >( initialView ); const [ selectedItem, setSelectedItem ] = useState< string >(); @@ -82,32 +91,20 @@ export default function BlockGuidelines() { ); const { createSuccessNotice } = useDispatch( noticesStore ); - const blockGuidelines = useSelect( - ( select ) => select( coreGuidelinesStore ).getBlockGuidelines(), - [] - ); - - const blockTypes = useSelect( - // @ts-ignore - ( select ) => select( blocksStore ).getBlockTypes(), - [] - ); - const rows = useMemo( () => - blockTypes - .filter( ( blockType ) => blockGuidelines[ blockType.name ] ) - .map( ( blockType ) => ( { - id: blockType.name, - label: blockType.title, - guidelines: blockGuidelines[ blockType.name ] ?? '', - icon: blockType.icon?.src, + contentBlocks + .filter( ( block ) => bySlug[ blockSlug( block.name ) ] ) + .map( ( block ) => ( { + id: block.name, + label: block.title, + guidelines: + bySlug[ blockSlug( block.name ) ]?.content ?? '', + icon: block.icon?.src, } ) ), - [ blockGuidelines, blockTypes ] + [ contentBlocks, bySlug ] ); - const { setBlockGuideline } = useDispatch( coreGuidelinesStore ); - const handleRowClick = ( id: string ) => { setSelectedItem( id ); setIsOpen( true ); @@ -119,16 +116,14 @@ export default function BlockGuidelines() { id: 'edit', label: __( 'Edit' ), callback: ( items: DataRow[] ) => { - const item = items[ 0 ]; - handleRowClick( item.id ); + handleRowClick( items[ 0 ].id ); }, }, { id: 'remove', label: __( 'Remove' ), callback: ( items: DataRow[] ) => { - const item = items[ 0 ]; - setItemToDelete( item ); + setItemToDelete( items[ 0 ] ); }, }, ], @@ -139,22 +134,20 @@ export default function BlockGuidelines() { if ( ! itemToDelete ) { return; } - const oldValue = blockGuidelines[ itemToDelete.id ]; - // We need to pass an empty string to remove the guideline. - // This is because the API will only remove the guideline if the value is an empty string. - setBlockGuideline( itemToDelete.id, '' ); + const row = bySlug[ blockSlug( itemToDelete.id ) ]; + if ( ! row ) { + setItemToDelete( null ); + return; + } setBusy( true ); - saveGuidelines() + deleteGuidelineRow( row.id ) .then( () => { setError( null ); createSuccessNotice( __( 'Guidelines removed.' ), { type: 'snackbar', } ); } ) - .catch( ( e: Error ) => { - setError( e.message ); - setBlockGuideline( itemToDelete.id, oldValue ); - } ) + .catch( ( e: Error ) => setError( e.message ) ) .finally( () => { setBusy( false ); setItemToDelete( null ); @@ -214,8 +207,7 @@ export default function BlockGuidelines() { actions={ actions } config={ { perPageSizes: [ PER_PAGE ] } } onChangeSelection={ ( items ) => { - const id = items[ 0 ]; - handleRowClick( id ); + handleRowClick( items[ 0 ] ); } } defaultLayouts={ { list: true, @@ -244,6 +236,9 @@ export default function BlockGuidelines() { ) } ( null ); const [ showClearConfirmation, setShowClearConfirmation ] = useState( false ); - const { value } = useSelect( - ( select ) => ( { - value: select( coreGuidelinesStore ).getGuideline( slug ), - } ), - [ slug ] - ); - - const [ draft, setDraft ] = useState( value ); - useEffect( () => setDraft( value ), [ value ] ); + const [ draft, setDraft ] = useState( content ); + useEffect( () => setDraft( content ), [ content ] ); const data = useMemo( () => ( { guidelines: draft } ), [ draft ] ); @@ -46,15 +50,15 @@ export default function GuidelineAccordionForm( { slug }: { slug: string } ) { { id: 'guidelines', label: sprintf( - /* translators: %s: Guideline category. */ + /* translators: %s: Guideline section title. */ __( '%s guidelines' ), - slug + scope.title ), type: 'text', Edit: 'textarea', }, ], - [ slug ] + [ scope.title ] ); const form: Form = useMemo( @@ -67,9 +71,14 @@ export default function GuidelineAccordionForm( { slug }: { slug: string } ) { const handleSave = ( event: React.FormEvent< HTMLFormElement > ) => { event.preventDefault(); - setGuideline( slug, draft ); setLoading( true ); - saveGuidelines() + saveGuidelineRow( + scopeSlug( scope.slug ), + scope.title, + draft, + existingId, + query + ) .then( () => { setError( null ); createSuccessNotice( __( 'Guidelines saved.' ), { @@ -91,29 +100,27 @@ export default function GuidelineAccordionForm( { slug }: { slug: string } ) { const handleClearClick = () => setShowClearConfirmation( true ); const handleClearConfirm = () => { - const oldValue = draft; - - // We need to pass an empty string to remove the guideline. - // This is because the API will only remove the guideline if the value is an empty string. - setGuideline( slug, '' ); + if ( ! existingId ) { + setShowClearConfirmation( false ); + return; + } setLoading( true ); - saveGuidelines() + deleteGuidelineRow( existingId ) .then( () => { setError( null ); createSuccessNotice( __( 'Guidelines cleared.' ), { type: 'snackbar', } ); } ) - .catch( ( e: Error ) => { + .catch( ( e: Error ) => setError( sprintf( /* translators: %s: Error message. */ __( 'Error clearing guidelines: %s' ), e.message ) - ); - setGuideline( slug, oldValue ); - } ) + ) + ) .finally( () => { setLoading( false ); setShowClearConfirmation( false ); @@ -150,7 +157,7 @@ export default function GuidelineAccordionForm( { slug }: { slug: string } ) { - - - - ) } - - ); -} diff --git a/routes/guidelines/data.ts b/routes/guidelines/data.ts new file mode 100644 index 00000000000000..041f9639bf66f1 --- /dev/null +++ b/routes/guidelines/data.ts @@ -0,0 +1,209 @@ +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; +import { useSelect, dispatch } from '@wordpress/data'; +import { store as coreStore, useEntityRecords } from '@wordpress/core-data'; +import { + store as blocksStore, + privateApis as blocksPrivateApis, +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { unlock } from '@wordpress/routes-lock-unlock'; +import type { + Scope, + GuidelineRow, + ContentBlock, + GuidelineQuery, +} from './types'; + +const { isContentBlock } = unlock( blocksPrivateApis ); + +export const KNOWLEDGE_KIND = 'postType'; +export const KNOWLEDGE_NAME = 'wp_knowledge'; + +const SCOPE_PREFIX = 'guideline-'; +const BLOCK_PREFIX = 'guideline-block-'; + +// Sentinel slug used while the registry/block list is still empty so the +// collection query matches nothing instead of every knowledge row. +const NO_MATCH_SLUG = 'guideline-__none__'; + +/** + * The slug for a registry scope row, e.g. `guideline-copy`. + * @param scope Scope key. + */ +export function scopeSlug( scope: string ): string { + return `${ SCOPE_PREFIX }${ scope }`; +} + +/** + * The slug for a block guideline row, e.g. `guideline-block-core-paragraph`. + * Forward-only and unambiguous; the canonical block name lives in the title. + * @param blockName Exact block name (e.g. `core/paragraph`). + */ +export function blockSlug( blockName: string ): string { + return `${ BLOCK_PREFIX }${ blockName.replace( '/', '-' ) }`; +} + +/** + * The content-role blocks from the client block registry — the authoritative + * list of blocks that can carry guidelines. + */ +export function useContentBlocks(): ContentBlock[] { + return useSelect( + ( s ) => + // @ts-ignore - getBlockTypes is untyped in this context. + s( blocksStore ) + .getBlockTypes() + .filter( ( block: ContentBlock ) => + isContentBlock( block.name ) + ), + [] + ); +} + +interface GuidelineData { + scopes: Scope[]; + contentBlocks: ContentBlock[]; + bySlug: Record< string, GuidelineRow >; + query: GuidelineQuery; + isLoading: boolean; +} + +/** + * Reads the guideline scope registry and the per-scope/per-block rows in one + * slug-filtered collection request, indexed by slug. + */ +export function useGuidelineData(): GuidelineData { + const { records: scopeRecords, isResolving: scopesResolving } = + useEntityRecords( 'root', 'guidelineScope' ); + + const contentBlocks = useContentBlocks(); + + const scopes: Scope[] = useMemo( + () => + ( ( scopeRecords as Scope[] ) ?? [] ) + .map( ( s ) => ( { + slug: s.slug, + title: s.title, + description: s.description, + order: s.order ?? 0, + } ) ) + .sort( ( a, b ) => a.order - b.order ), + [ scopeRecords ] + ); + + const slugs = useMemo( () => { + const list = [ + ...scopes.map( ( s ) => scopeSlug( s.slug ) ), + ...contentBlocks.map( ( b ) => blockSlug( b.name ) ), + ]; + return list.length > 0 ? list : [ NO_MATCH_SLUG ]; + }, [ scopes, contentBlocks ] ); + + const query: GuidelineQuery = useMemo( + () => ( { + slug: slugs, + status: [ 'publish', 'draft' ], + context: 'edit', + per_page: -1, + } ), + [ slugs ] + ); + + const { records: rowRecords, isResolving: rowsResolving } = + useEntityRecords( KNOWLEDGE_KIND, KNOWLEDGE_NAME, query ); + + const bySlug = useMemo( () => { + const map: Record< string, GuidelineRow > = {}; + for ( const row of rowRecords ?? [] ) { + map[ row.slug ] = { + id: row.id, + content: row.content?.raw ?? '', + }; + } + return map; + }, [ rowRecords ] ); + + return { + scopes, + contentBlocks, + bySlug, + query, + isLoading: scopesResolving || rowsResolving, + }; +} + +/** + * Creates (or updates) a guideline row for the given slug. + * + * The server forces the `guideline` term and, for registry scopes, re-stamps + * the title; block rows keep the canonical block name passed as the title. + * + * @param slug Row slug. + * @param title Title to send (registry title for scopes, exact block name for blocks). + * @param content Guideline text. + * @param existingId Existing row id, or undefined to create. + * @param query The collection query to invalidate after a create. + */ +export async function saveGuidelineRow( + slug: string, + title: string, + content: string, + existingId: number | undefined, + query: GuidelineQuery +): Promise< void > { + const { + editEntityRecord, + saveEditedEntityRecord, + saveEntityRecord, + invalidateResolution, + } = dispatch( coreStore ); + + if ( existingId ) { + await editEntityRecord( KNOWLEDGE_KIND, KNOWLEDGE_NAME, existingId, { + content, + } ); + await saveEditedEntityRecord( + KNOWLEDGE_KIND, + KNOWLEDGE_NAME, + existingId, + undefined, + { throwOnError: true } + ); + return; + } + + await saveEntityRecord( + KNOWLEDGE_KIND, + KNOWLEDGE_NAME, + { slug, title, content, status: 'publish' }, + { throwOnError: true } + ); + + // A freshly created row's id isn't in the slug-filtered query's resolved id + // list yet; re-resolve so it shows up. + invalidateResolution( 'getEntityRecords', [ + KNOWLEDGE_KIND, + KNOWLEDGE_NAME, + query, + ] ); +} + +/** + * Deletes a guideline row (force, so no empty rows linger and the slug frees up). + * @param id Row id. + */ +export async function deleteGuidelineRow( id: number ): Promise< void > { + await dispatch( coreStore ).deleteEntityRecord( + KNOWLEDGE_KIND, + KNOWLEDGE_NAME, + id, + { force: true }, + { throwOnError: true } + ); +} diff --git a/routes/guidelines/entity.ts b/routes/guidelines/entity.ts new file mode 100644 index 00000000000000..95a9ac792f9a6c --- /dev/null +++ b/routes/guidelines/entity.ts @@ -0,0 +1,32 @@ +/** + * WordPress dependencies + */ +import { dispatch } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { __ } from '@wordpress/i18n'; + +let registered = false; + +/** + * Registers the read-only guideline scopes registry as a core-data `root` + * entity so the Settings page can consume it with `useEntityRecords`, the same + * way it consumes post statuses. Idempotent. + */ +export function registerGuidelineScopeEntity(): void { + if ( registered ) { + return; + } + registered = true; + + dispatch( coreStore ).addEntities( [ + { + label: __( 'Guideline Scope' ), + name: 'guidelineScope', + kind: 'root', + baseURL: '/wp/v2/knowledge/guideline-scopes', + plural: 'guidelineScopes', + key: 'slug', + supportsPagination: false, + }, + ] ); +} diff --git a/routes/guidelines/import-export.ts b/routes/guidelines/import-export.ts new file mode 100644 index 00000000000000..866d505c83b642 --- /dev/null +++ b/routes/guidelines/import-export.ts @@ -0,0 +1,181 @@ +/** + * WordPress dependencies + */ +import { downloadBlob } from '@wordpress/blob'; +import { dispatch } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import { + scopeSlug, + blockSlug, + saveGuidelineRow, + deleteGuidelineRow, +} from './data'; +import type { + Scope, + GuidelineRow, + ContentBlock, + GuidelineQuery, + GuidelineImportData, +} from './types'; + +// Matches the block name validation WP_Block_Type_Registry::register() uses. +const BLOCK_NAME_RE = /^[a-z0-9-]+\/[a-z0-9-]+$/; + +function isValidImport( data: unknown ): data is GuidelineImportData { + return ( + !! data && + typeof data === 'object' && + 'guideline_categories' in data && + typeof ( data as Record< string, unknown > ).guideline_categories === + 'object' && + ( data as Record< string, unknown > ).guideline_categories !== null + ); +} + +function readGuidelines( value: unknown ): string { + if ( value && typeof value === 'object' && 'guidelines' in value ) { + const text = ( value as { guidelines?: unknown } ).guidelines; + return typeof text === 'string' ? text : ''; + } + return ''; +} + +/** + * Exports the current guidelines to a JSON file. Same shape and filename as the + * singleton era so existing tooling keeps working. + * + * @param scopes Registry scopes. + * @param bySlug Resolved rows indexed by slug. + * @param contentBlocks Content-role blocks from the client registry. + */ +export function exportGuidelines( + scopes: Scope[], + bySlug: Record< string, GuidelineRow >, + contentBlocks: ContentBlock[] +): void { + const categories: Record< string, unknown > = {}; + + for ( const scope of scopes ) { + categories[ scope.slug ] = { + guidelines: bySlug[ scopeSlug( scope.slug ) ]?.content ?? '', + }; + } + + const blocks: Record< string, { guidelines: string } > = {}; + for ( const block of contentBlocks ) { + const content = bySlug[ blockSlug( block.name ) ]?.content; + if ( content ) { + blocks[ block.name ] = { guidelines: content }; + } + } + categories.blocks = blocks; + + const now = new Date(); + const exportDate = [ + now.getFullYear(), + String( now.getMonth() + 1 ).padStart( 2, '0' ), + String( now.getDate() ).padStart( 2, '0' ), + ].join( '-' ); + + downloadBlob( + `guidelines-${ exportDate }.json`, + JSON.stringify( { guideline_categories: categories }, null, 2 ), + 'application/json' + ); + + dispatch( noticesStore ).createSuccessNotice( + __( 'Guidelines exported.' ), + { + type: 'snackbar', + } + ); +} + +/** + * Imports guidelines from a JSON file, fully replacing the current ones. + * + * @param file The JSON file. + * @param scopes Registry scopes. + * @param bySlug Resolved rows indexed by slug. + * @param contentBlocks Content-role blocks from the client registry. + * @param query Collection query to invalidate after creates. + */ +export async function importGuidelines( + file: File, + scopes: Scope[], + bySlug: Record< string, GuidelineRow >, + contentBlocks: ContentBlock[], + query: GuidelineQuery +): Promise< void > { + const parsed: unknown = JSON.parse( await file.text() ); + + if ( ! isValidImport( parsed ) ) { + throw new Error( + __( + 'Check that your file contains valid JSON markup and try again.' + ) + ); + } + + const categories = parsed.guideline_categories; + + // Registry scopes: set or clear each one. + for ( const scope of scopes ) { + const slug = scopeSlug( scope.slug ); + const value = readGuidelines( categories[ scope.slug ] ); + const existingId = bySlug[ slug ]?.id; + + if ( value ) { + await saveGuidelineRow( + slug, + scope.title, + value, + existingId, + query + ); + } else if ( existingId ) { + await deleteGuidelineRow( existingId ); + } + } + + const importedBlocks = + categories.blocks && typeof categories.blocks === 'object' + ? ( categories.blocks as Record< string, unknown > ) + : {}; + + // Save every imported block guideline (validated block names only). + for ( const [ name, value ] of Object.entries( importedBlocks ) ) { + if ( ! BLOCK_NAME_RE.test( name ) ) { + continue; + } + const slug = blockSlug( name ); + const text = readGuidelines( value ); + const existingId = bySlug[ slug ]?.id; + + if ( text ) { + await saveGuidelineRow( slug, name, text, existingId, query ); + } else if ( existingId ) { + await deleteGuidelineRow( existingId ); + } + } + + // Clear existing block rows the import omits. + for ( const block of contentBlocks ) { + const existingId = bySlug[ blockSlug( block.name ) ]?.id; + if ( existingId && ! ( block.name in importedBlocks ) ) { + await deleteGuidelineRow( existingId ); + } + } + + dispatch( noticesStore ).createSuccessNotice( + __( 'Guidelines imported.' ), + { + type: 'snackbar', + } + ); +} diff --git a/routes/guidelines/package.json b/routes/guidelines/package.json index 3d2e7fea111f47..2fe6bd3418faed 100644 --- a/routes/guidelines/package.json +++ b/routes/guidelines/package.json @@ -10,12 +10,11 @@ }, "dependencies": { "@wordpress/admin-ui": "file:../../packages/admin-ui", - "@wordpress/api-fetch": "file:../../packages/api-fetch", "@wordpress/blob": "file:../../packages/blob", "@wordpress/base-styles": "file:../../packages/base-styles", "@wordpress/components": "file:../../packages/components", + "@wordpress/core-data": "file:../../packages/core-data", "@wordpress/data": "file:../../packages/data", - "@wordpress/date": "file:../../packages/date", "@wordpress/element": "file:../../packages/element", "@wordpress/i18n": "file:../../packages/i18n", "@wordpress/icons": "file:../../packages/icons", diff --git a/routes/guidelines/route.ts b/routes/guidelines/route.ts index d901ef978291a4..37bb6cc2d29832 100644 --- a/routes/guidelines/route.ts +++ b/routes/guidelines/route.ts @@ -7,8 +7,12 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import { bootstrapBlockRegistry } from './bootstrap-block-registry'; +import { registerGuidelineScopeEntity } from './entity'; export const route = { - beforeLoad: bootstrapBlockRegistry, + beforeLoad: () => { + bootstrapBlockRegistry(); + registerGuidelineScopeEntity(); + }, title: () => __( 'Guidelines' ), }; diff --git a/routes/guidelines/stage.tsx b/routes/guidelines/stage.tsx index 505a6362fd6bce..99da64c936df1d 100644 --- a/routes/guidelines/stage.tsx +++ b/routes/guidelines/stage.tsx @@ -2,14 +2,9 @@ * WordPress dependencies */ import { Page } from '@wordpress/admin-ui'; -import { __, sprintf } from '@wordpress/i18n'; -import { useEffect, useState } from '@wordpress/element'; -import { - Spinner, - Navigator, - Notice, - __experimentalVStack as VStack, -} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useEffect, useMemo, useState } from '@wordpress/element'; +import { Spinner, __experimentalVStack as VStack } from '@wordpress/components'; /** * Internal dependencies @@ -17,75 +12,45 @@ import { import './style.scss'; import GuidelineAccordion from './components/guideline-accordion'; import GuidelineAccordionForm from './components/guideline-accordion-form'; -import { fetchGuidelines } from './api'; import BlockGuidelines from './components/block-guidelines'; import GuidelineActionsSection from './components/guideline-actions-section'; -import RevisionHistory from './components/revision-history'; +import { useGuidelineData, scopeSlug } from './data'; -const GUIDELINE_ITEMS = [ - { - title: __( 'Site' ), - description: __( - "Describe your site's purpose, goals, and primary audience." - ), +// The Blocks section is not a registry scope; it renders the per-block rows. +// Placed between Images (30) and Additional (50) to keep the historical order. +const BLOCKS_ORDER = 40; - slug: 'site', - }, - { - title: __( 'Copy' ), - description: __( - 'Set your writing standards for tone, voice, style, and formatting.' - ), - - slug: 'copy', - }, - { - title: __( 'Images' ), - description: __( - 'Outline your style, dimensions, formats, mood and aesthetic preferences.' - ), - - slug: 'images', - }, - { - title: __( 'Blocks' ), - description: __( - 'Create tailored guidelines for specific block types.' - ), - slug: 'blocks', - }, - { - title: __( 'Additional' ), - description: __( 'Add additional guidelines.' ), - slug: 'additional', - }, -]; - -const KNOWN_VIEWS = [ 'revision-history' ]; +function GuidelinesPage() { + const { scopes, contentBlocks, bySlug, query, isLoading } = + useGuidelineData(); -function getInitialNavigatorPath() { - if ( window?.location?.href ) { - const url = new URL( window.location.href ); - const view = url.searchParams.get( 'view' ) ?? ''; - if ( KNOWN_VIEWS.includes( view ) ) { - return `/${ view }`; + // Only show the spinner on the first load. Later refetches (e.g. after a + // save re-resolves the collection) must not unmount the sections, or the + // accordions would collapse and lose any in-progress edits. + const [ hasLoaded, setHasLoaded ] = useState( false ); + useEffect( () => { + if ( ! isLoading ) { + setHasLoaded( true ); } - } + }, [ isLoading ] ); - return '/'; -} + const sections = useMemo( () => { + const scopeSections = scopes.map( ( scope ) => ( { + key: scope.slug, + order: scope.order, + scope, + } ) ); -function GuidelinesPage() { - const [ loading, setLoading ] = useState( true ); - const [ error, setError ] = useState< string | null >( null ); + const blocksSection = { + key: 'blocks', + order: BLOCKS_ORDER, + scope: null, + }; - useEffect( () => { - // Populate the store with the guidelines. - fetchGuidelines() - .then( () => setError( null ) ) - .catch( ( e: Error ) => setError( e.message ) ) - .finally( () => setLoading( false ) ); - }, [] ); + return [ ...scopeSections, blocksSection ].sort( + ( a, b ) => a.order - b.order + ); + }, [ scopes ] ); return ( - { error && ( -
- - - { sprintf( - /* translators: %s: Error message. */ - __( 'Error loading guidelines: %s' ), - error - ) } - -

- { __( - 'Please try again. If the problem persists, contact support.' - ) } -

-
-
- ) } - { loading ? ( + { ! hasLoaded ? (
) : ( - ! error && ( - - - - { /* - * Disable reason: The `list` ARIA role is redundant but - * Safari+VoiceOver won't announce the list otherwise. - */ - /* eslint-disable jsx-a11y/no-redundant-roles */ } -
    - { GUIDELINE_ITEMS.map( ( item ) => ( -
  • - - { item.slug === 'blocks' ? ( - - ) : ( - - ) } - -
  • - ) ) } -
- { /* eslint-enable jsx-a11y/no-redundant-roles */ } - -
-
- - - -
- ) + + { /* + * Disable reason: The `list` ARIA role is redundant but + * Safari+VoiceOver won't announce the list otherwise. + */ + /* eslint-disable jsx-a11y/no-redundant-roles */ } +
    + { sections.map( ( section ) => + section.scope ? ( +
  • + + + +
  • + ) : ( +
  • + + + +
  • + ) + ) } +
+ { /* eslint-enable jsx-a11y/no-redundant-roles */ } + +
) }
); diff --git a/routes/guidelines/store.ts b/routes/guidelines/store.ts deleted file mode 100644 index 63398ce5e5a887..00000000000000 --- a/routes/guidelines/store.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * WordPress dependencies - */ -import { createReduxStore, register } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import type { - Categories, - GuidelinesState, - RestGuidelinesResponse, -} from './types'; - -export type { Categories }; -export const STORE_NAME = 'core/guidelines'; - -const DEFAULT_STATE: GuidelinesState = { - id: null, - status: null, - categories: { - site: '', - copy: '', - images: '', - additional: '', - blocks: {}, - }, -}; - -const CATEGORIES = [ 'site', 'copy', 'images', 'additional', 'blocks' ]; - -const actions = { - setFromResponse( response: RestGuidelinesResponse ) { - return { - type: 'SET_FROM_RESPONSE' as const, - response, - }; - }, - setGuideline( category: string, value: string ) { - return { - type: 'SET_GUIDELINE' as const, - category, - value, - }; - }, - setBlockGuideline( blockName: string, value: string ) { - return { - type: 'SET_BLOCK_GUIDELINE' as const, - blockName, - value, - }; - }, -}; - -type Action = ReturnType< ( typeof actions )[ keyof typeof actions ] >; - -function parseResponse( - response: RestGuidelinesResponse | null | undefined -): Partial< GuidelinesState > { - if ( ! response || typeof response !== 'object' ) { - return {}; - } - - const categoriesFromResponse = response.guideline_categories ?? {}; - - const result = { - id: response.id ?? null, - status: response.status ?? null, - categories: { - site: '', - copy: '', - images: '', - additional: '', - blocks: {}, - }, - }; - - CATEGORIES.forEach( ( category ) => { - const guidelines = categoriesFromResponse?.[ category ]?.guidelines; - if ( typeof guidelines === 'string' ) { - result.categories[ category ] = guidelines; - } else if ( category === 'blocks' ) { - const blocks = categoriesFromResponse?.blocks ?? {}; - for ( const [ blockName, blockData ] of Object.entries( blocks ) ) { - result.categories.blocks[ blockName ] = blockData?.guidelines; - } - } - } ); - - return result; -} - -function reducer( - state: GuidelinesState = DEFAULT_STATE, - action: Action -): GuidelinesState { - switch ( action.type ) { - case 'SET_FROM_RESPONSE': - return { - ...state, - ...parseResponse( action.response ), - }; - case 'SET_GUIDELINE': - return { - ...state, - categories: { - ...state.categories, - [ action.category ]: action.value, - }, - }; - case 'SET_BLOCK_GUIDELINE': { - const blocks = { - ...state.categories.blocks, - [ action.blockName ]: action.value, - }; - - if ( action.value === undefined ) { - delete blocks[ action.blockName ]; - } - - return { - ...state, - categories: { - ...state.categories, - blocks, - }, - }; - } - default: - return state; - } -} - -const selectors = { - getGuideline( - state: GuidelinesState, - category: string - ): string | Record< string, string > { - return state.categories[ category ]; - }, - getAllGuidelines( state: GuidelinesState ): Categories { - return state.categories; - }, - getBlockGuidelines( state: GuidelinesState ): Record< string, string > { - return state.categories.blocks; - }, - getBlockGuideline( state: GuidelinesState, blockName: string ): string { - return state.categories.blocks[ blockName ] ?? ''; - }, - getId( state: GuidelinesState ): number | null { - return state.id; - }, - getStatus( state: GuidelinesState ): string | null { - return state.status; - }, -}; - -export const store = createReduxStore( STORE_NAME, { - reducer, - actions, - selectors, -} ); - -register( store ); diff --git a/routes/guidelines/types.ts b/routes/guidelines/types.ts index e30211b9f7b015..305d0b4f6955bb 100644 --- a/routes/guidelines/types.ts +++ b/routes/guidelines/types.ts @@ -4,38 +4,41 @@ import type { ReactNode } from 'react'; -export interface Categories { - site: string; - copy: string; - images: string; - additional: string; - blocks: Record< string, string >; -} - -export interface GuidelinesState { - id: number | null; - status: string | null; - categories: Categories; +/** + * A guideline scope from the `/wp/v2/knowledge/guideline-scopes` registry. + */ +export interface Scope { + slug: string; + title: string; + description: string; + order: number; } -interface BlockGuideline { - guidelines: string | Record< string, string >; +/** + * A resolved guideline row (scope or block), indexed by slug. + */ +export interface GuidelineRow { + id: number; + content: string; } -export interface RestGuidelinesResponse { - id: number; - status: string; - guideline_categories?: Record< string, BlockGuideline >; +/** + * The minimal block-type shape the Guidelines UI reads from the block registry. + */ +export interface ContentBlock { + name: string; + title: string; + icon?: { src?: unknown }; } -export interface GuidelinesImportData { - guideline_categories: { - site?: { guidelines?: string }; - copy?: { guidelines?: string }; - images?: { guidelines?: string }; - additional?: { guidelines?: string }; - blocks?: Record< string, { guidelines?: string } >; - }; +/** + * The collection query used to read guideline rows by slug. + */ +export interface GuidelineQuery { + slug: string[]; + status: string[]; + context: string; + per_page: number; } export interface GuidelineAccordionProps { @@ -44,11 +47,11 @@ export interface GuidelineAccordionProps { children: ReactNode; } -export interface GuidelinesRevision { - id: number; - date: string; - author: number; - _embedded?: { - author: Array< { name: string } >; - }; +/** + * The on-disk import/export JSON shape (unchanged from the singleton era so + * existing files round-trip). Flat scopes carry `{ guidelines }`; `blocks` is a + * map of block name to `{ guidelines }`. + */ +export interface GuidelineImportData { + guideline_categories: Record< string, unknown >; } diff --git a/test/e2e/specs/admin/guidelines.spec.js b/test/e2e/specs/admin/guidelines.spec.js index 0218af36085d90..0fd15b29fae5b1 100644 --- a/test/e2e/specs/admin/guidelines.spec.js +++ b/test/e2e/specs/admin/guidelines.spec.js @@ -5,57 +5,65 @@ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); const SETTINGS_PAGE_PATH = 'options-general.php'; const GUIDELINES_PAGE_QUERY = 'page=guidelines-wp-admin'; -const GUIDELINES_REST_BASE = '/wp/v2/content-guidelines'; +const KNOWLEDGE_REST_BASE = '/wp/v2/knowledge'; -// Remove any existing singleton guideline post so each test starts from a -// clean slate. Uses REST for speed — this is test scaffolding, not the -// behavior under verification. +// Remove any existing guideline rows so each test starts from a clean slate. +// Uses REST for speed — this is test scaffolding, not the behavior under +// verification. Guideline rows are the `wp_knowledge` posts whose slug begins +// with `guideline-` (scopes and per-block rows alike). async function deleteAllGuidelines( requestUtils ) { - const guidelines = await requestUtils.rest( { - path: GUIDELINES_REST_BASE, + const rows = await requestUtils.rest( { + path: KNOWLEDGE_REST_BASE, + params: { + per_page: 100, + context: 'edit', + status: [ 'publish', 'draft', 'private' ], + }, } ); - if ( guidelines?.id ) { - await requestUtils.rest( { - path: `${ GUIDELINES_REST_BASE }/${ guidelines.id }`, - method: 'DELETE', - params: { force: true }, - } ); + for ( const row of rows ?? [] ) { + if ( + typeof row?.slug === 'string' && + row.slug.startsWith( 'guideline-' ) + ) { + await requestUtils.rest( { + path: `${ KNOWLEDGE_REST_BASE }/${ row.id }`, + method: 'DELETE', + params: { force: true }, + } ); + } } } -// Locate the list item wrapping a category's Collapsible Card. Scoping -// subsequent queries to this locator isolates one category (its trigger, +// Locate the list item wrapping a section's Collapsible Card. Scoping +// subsequent queries to this locator isolates one section (its trigger, // form, and Save button) from the others. -function getCategoryCard( page, title ) { +function getSectionCard( page, title ) { return page.getByRole( 'listitem' ).filter( { - has: page.getByRole( 'button', { name: title } ), + has: page.getByRole( 'button', { name: title, exact: true } ), } ); } -// Expand a category accordion and fill its textarea, then click Save and +// Expand a section accordion and fill its textarea, then click Save and // wait for the success snackbar. -async function saveCategoryGuidelines( page, title, text ) { - const card = getCategoryCard( page, title ); +async function saveSectionGuidelines( page, title, text ) { + const card = getSectionCard( page, title ); - // Expand the accordion if it isn't already open. - const trigger = card.getByRole( 'button', { name: title } ); + const trigger = card.getByRole( 'button', { name: title, exact: true } ); if ( ( await trigger.getAttribute( 'aria-expanded' ) ) !== 'true' ) { await trigger.click(); } - // The DataForm renders a textarea whose accessible name is - // " guidelines" (lowercased slug from the field label). + // The DataForm renders a textarea whose accessible name is the field label + // " guidelines" (the registry scope title). const textarea = card.getByRole( 'textbox', { - name: `${ title.toLowerCase() } guidelines`, + name: `${ title } guidelines`, } ); await expect( textarea ).toBeVisible(); await textarea.fill( text ); await card.getByRole( 'button', { name: 'Save guidelines' } ).click(); - // Success snackbar is rendered at the document root, not inside the card. - // Scope to the snackbar testid to avoid matching the a11y-speak live region. await expect( page .getByTestId( 'snackbar' ) @@ -95,26 +103,43 @@ test.describe( 'Guidelines', () => { ); } ); - test( 'opens the Guidelines page from the Settings menu', async ( { + test( 'opens the Guidelines page and renders registry sections', async ( { page, admin, } ) => { - await admin.visitAdminPage( SETTINGS_PAGE_PATH ); - await page - .getByRole( 'navigation', { name: 'Main menu' } ) - .getByRole( 'link', { name: 'Guidelines' } ) - .click(); + await admin.visitAdminPage( SETTINGS_PAGE_PATH, GUIDELINES_PAGE_QUERY ); - // The page layout renders the "Guidelines" title as an h1 and - // the category accordions load once the initial fetch resolves. await expect( page.getByRole( 'heading', { name: 'Guidelines', level: 1 } ) ).toBeVisible(); - await expect( getCategoryCard( page, 'Copy' ) ).toBeVisible(); - await expect( getCategoryCard( page, 'Images' ) ).toBeVisible(); + + // Sections come from the wp_guideline_scopes registry plus the Blocks + // section the client injects. + await expect( getSectionCard( page, 'Site' ) ).toBeVisible(); + await expect( getSectionCard( page, 'Copy' ) ).toBeVisible(); + await expect( getSectionCard( page, 'Images' ) ).toBeVisible(); + await expect( getSectionCard( page, 'Blocks' ) ).toBeVisible(); + await expect( getSectionCard( page, 'Additional' ) ).toBeVisible(); + } ); + + test( 'does not expose revision history', async ( { page, admin } ) => { + await admin.visitAdminPage( SETTINGS_PAGE_PATH, GUIDELINES_PAGE_QUERY ); + await expect( getSectionCard( page, 'Copy' ) ).toBeVisible(); + + // The Actions card offers Import and Export, but not Revert / history. + await expect( + page.getByRole( 'button', { name: 'Export guidelines' } ) + ).toBeVisible(); + await expect( + page.getByRole( 'button', { name: 'Import guidelines' } ) + ).toBeVisible(); + await expect( page.getByText( 'Revert' ) ).toHaveCount( 0 ); + await expect( + page.getByRole( 'button', { name: 'View history' } ) + ).toHaveCount( 0 ); } ); - test( 'persists Copy and Images guidelines entered through the UI across a refresh', async ( { + test( 'persists Copy and Images guidelines across a refresh', async ( { page, admin, } ) => { @@ -122,35 +147,158 @@ test.describe( 'Guidelines', () => { const imagesText = 'Always include descriptive alt text.'; await admin.visitAdminPage( SETTINGS_PAGE_PATH, GUIDELINES_PAGE_QUERY ); + await expect( getSectionCard( page, 'Copy' ) ).toBeVisible(); - // Wait for the initial fetch to resolve — accordions only render - // after the loading spinner disappears. - await expect( getCategoryCard( page, 'Copy' ) ).toBeVisible(); + await saveSectionGuidelines( page, 'Copy', copyText ); + await saveSectionGuidelines( page, 'Images', imagesText ); - // Save Copy and Images through the UI, one category at a time. - await saveCategoryGuidelines( page, 'Copy', copyText ); - await saveCategoryGuidelines( page, 'Images', imagesText ); - - // Refresh the page — the "verify saved guidelines load correctly" - // step from the PR's testing instructions. await page.reload(); - await expect( getCategoryCard( page, 'Copy' ) ).toBeVisible(); - - // Re-expand each accordion and confirm the textareas were - // rehydrated with the values that were saved. Reading back from - // the UI (rather than REST) verifies the full round trip: the - // wp_knowledge CPT stored the post, the REST controller served - // it, the app hydrated its store, and the DataForm populated. - const copyCard = getCategoryCard( page, 'Copy' ); - await copyCard.getByRole( 'button', { name: 'Copy' } ).click(); + await expect( getSectionCard( page, 'Copy' ) ).toBeVisible(); + + // Reading back from the UI verifies the full round trip: a per-scope + // wp_knowledge row was created, the standard collection served it, and + // core-data hydrated the form. + const copyCard = getSectionCard( page, 'Copy' ); + await copyCard + .getByRole( 'button', { name: 'Copy', exact: true } ) + .click(); await expect( - copyCard.getByRole( 'textbox', { name: 'copy guidelines' } ) + copyCard.getByRole( 'textbox', { name: 'Copy guidelines' } ) ).toHaveValue( copyText ); - const imagesCard = getCategoryCard( page, 'Images' ); - await imagesCard.getByRole( 'button', { name: 'Images' } ).click(); + const imagesCard = getSectionCard( page, 'Images' ); + await imagesCard + .getByRole( 'button', { name: 'Images', exact: true } ) + .click(); await expect( - imagesCard.getByRole( 'textbox', { name: 'images guidelines' } ) + imagesCard.getByRole( 'textbox', { name: 'Images guidelines' } ) ).toHaveValue( imagesText ); } ); + + test( 'clears a scope guideline', async ( { page, admin } ) => { + await admin.visitAdminPage( SETTINGS_PAGE_PATH, GUIDELINES_PAGE_QUERY ); + await expect( getSectionCard( page, 'Copy' ) ).toBeVisible(); + + await saveSectionGuidelines( page, 'Copy', 'Temporary copy guidance.' ); + + const copyCard = getSectionCard( page, 'Copy' ); + await copyCard + .getByRole( 'button', { name: 'Clear guidelines' } ) + .click(); + + // Confirm the clear in the dialog. + await page + .getByRole( 'dialog' ) + .getByRole( 'button', { name: 'Clear guidelines' } ) + .click(); + + await expect( + page + .getByTestId( 'snackbar' ) + .filter( { hasText: 'Guidelines cleared.' } ) + ).toBeVisible(); + + await page.reload(); + const reopened = getSectionCard( page, 'Copy' ); + await reopened + .getByRole( 'button', { name: 'Copy', exact: true } ) + .click(); + await expect( + reopened.getByRole( 'textbox', { name: 'Copy guidelines' } ) + ).toHaveValue( '' ); + } ); + + test( 'adds a block guideline', async ( { page, admin } ) => { + await admin.visitAdminPage( SETTINGS_PAGE_PATH, GUIDELINES_PAGE_QUERY ); + + const blocksCard = getSectionCard( page, 'Blocks' ); + await blocksCard + .getByRole( 'button', { name: 'Blocks', exact: true } ) + .click(); + await blocksCard + .getByRole( 'button', { name: 'Add guidelines' } ) + .click(); + + const dialog = page.getByRole( 'dialog', { name: 'Add guidelines' } ); + await expect( dialog ).toBeVisible(); + + // Pick a content block in the combobox. + const combobox = dialog.getByRole( 'combobox', { name: 'Block' } ); + await combobox.click(); + await combobox.fill( 'Paragraph' ); + await page + .getByRole( 'option', { name: 'Paragraph', exact: true } ) + .click(); + + await dialog + .getByRole( 'textbox', { name: 'Guideline text' } ) + .fill( 'Keep paragraphs short.' ); + await dialog.getByRole( 'button', { name: 'Save guidelines' } ).click(); + + await expect( + page + .getByTestId( 'snackbar' ) + .filter( { hasText: 'Guidelines saved.' } ) + ).toBeVisible(); + + // The block now appears in the Blocks list. + await expect( + getSectionCard( page, 'Blocks' ).getByText( 'Paragraph' ) + ).toBeVisible(); + } ); + + test( 'exports and re-imports guidelines', async ( { page, admin } ) => { + const copyText = 'Round-trip copy guidance.'; + + await admin.visitAdminPage( SETTINGS_PAGE_PATH, GUIDELINES_PAGE_QUERY ); + await expect( getSectionCard( page, 'Copy' ) ).toBeVisible(); + await saveSectionGuidelines( page, 'Copy', copyText ); + + // Export and capture the downloaded file. + const downloadPromise = page.waitForEvent( 'download' ); + await page.getByRole( 'button', { name: 'Export guidelines' } ).click(); + const download = await downloadPromise; + const exportPath = await download.path(); + + // Wipe everything, then import the file back. + await page.evaluate( async () => { + const rows = await window.wp.apiFetch( { + path: '/wp/v2/knowledge?per_page=100&context=edit&status=publish,draft,private', + } ); + for ( const row of rows ) { + if ( row.slug && row.slug.startsWith( 'guideline-' ) ) { + await window.wp.apiFetch( { + path: `/wp/v2/knowledge/${ row.id }?force=true`, + method: 'DELETE', + } ); + } + } + } ); + await page.reload(); + await expect( getSectionCard( page, 'Copy' ) ).toBeVisible(); + + const fileChooserPromise = page.waitForEvent( 'filechooser' ); + await page.getByRole( 'button', { name: 'Import guidelines' } ).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles( exportPath ); + + await page + .getByRole( 'dialog' ) + .getByRole( 'button', { name: 'Continue' } ) + .click(); + + await expect( + page + .getByTestId( 'snackbar' ) + .filter( { hasText: 'Guidelines imported.' } ) + ).toBeVisible(); + + const copyCard = getSectionCard( page, 'Copy' ); + await copyCard + .getByRole( 'button', { name: 'Copy', exact: true } ) + .click(); + await expect( + copyCard.getByRole( 'textbox', { name: 'Copy guidelines' } ) + ).toHaveValue( copyText ); + } ); } ); diff --git a/tools/eslint/suppressions.json b/tools/eslint/suppressions.json index 5dd51eba8c8279..5906f2eefb5490 100644 --- a/tools/eslint/suppressions.json +++ b/tools/eslint/suppressions.json @@ -1653,11 +1653,6 @@ "count": 2 } }, - "routes/guidelines/components/revision-history.tsx": { - "@wordpress/use-recommended-components": { - "count": 3 - } - }, "routes/guidelines/stage.tsx": { "@wordpress/no-non-module-stylesheet-imports": { "count": 1 From 2c64d878600aaaf8a5ce8f362209c24d1bb3f903 Mon Sep 17 00:00:00 2001 From: Aagam Shah <aagam.shah@automattic.com> Date: Wed, 17 Jun 2026 10:11:45 +0530 Subject: [PATCH 2/4] Knowledge: Fix editing guideline rows loaded after a page refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Importing onto (or editing) a guideline row that was loaded via the collection request after a reload threw "Cannot read properties of undefined (reading 'content')". The `wp_knowledge` (postType) entity already fetches with `context: 'edit'` via its `baseURLParams`, so the collection response includes raw field values regardless of the query. Passing `context: 'edit'` in the query as well only changed the *storage bucket* to `edit`, where `editEntityRecord`/`getRawEntityRecord` (which read the `default` bucket) could not find the row — so `editEntityRecord` dereferenced `undefined`. - Drop `context: 'edit'` from the collection query; raw content still arrives via the entity baseURLParams, and rows now land in the `default` bucket where edits resolve. - Pass options to `saveEditedEntityRecord` in the correct argument position so `throwOnError` is honored on updates. - Gate the loading spinner on `hasResolved` rather than `isResolving` so the form doesn't mount with empty content and clobber freshly-typed text when the rows arrive. - Add an e2e regression test that edits a scope guideline after a reload. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- routes/guidelines/data.ts | 16 ++++++++------ routes/guidelines/types.ts | 8 ++++++- test/e2e/specs/admin/guidelines.spec.js | 28 +++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/routes/guidelines/data.ts b/routes/guidelines/data.ts index 041f9639bf66f1..5538de94c03e4f 100644 --- a/routes/guidelines/data.ts +++ b/routes/guidelines/data.ts @@ -79,7 +79,7 @@ interface GuidelineData { * slug-filtered collection request, indexed by slug. */ export function useGuidelineData(): GuidelineData { - const { records: scopeRecords, isResolving: scopesResolving } = + const { records: scopeRecords, hasResolved: scopesResolved } = useEntityRecords( 'root', 'guidelineScope' ); const contentBlocks = useContentBlocks(); @@ -109,14 +109,16 @@ export function useGuidelineData(): GuidelineData { () => ( { slug: slugs, status: [ 'publish', 'draft' ], - context: 'edit', per_page: -1, } ), [ slugs ] ); - const { records: rowRecords, isResolving: rowsResolving } = - useEntityRecords( KNOWLEDGE_KIND, KNOWLEDGE_NAME, query ); + const { records: rowRecords, hasResolved: rowsResolved } = useEntityRecords( + KNOWLEDGE_KIND, + KNOWLEDGE_NAME, + query + ); const bySlug = useMemo( () => { const map: Record< string, GuidelineRow > = {}; @@ -134,7 +136,10 @@ export function useGuidelineData(): GuidelineData { contentBlocks, bySlug, query, - isLoading: scopesResolving || rowsResolving, + // Use hasResolved (not isResolving): isResolving is briefly false before + // the rows query starts, which would let the page render with empty + // content and clobber freshly-typed text once the rows arrive. + isLoading: ! scopesResolved || ! rowsResolved, }; } @@ -172,7 +177,6 @@ export async function saveGuidelineRow( KNOWLEDGE_KIND, KNOWLEDGE_NAME, existingId, - undefined, { throwOnError: true } ); return; diff --git a/routes/guidelines/types.ts b/routes/guidelines/types.ts index 305d0b4f6955bb..262b99692f036c 100644 --- a/routes/guidelines/types.ts +++ b/routes/guidelines/types.ts @@ -33,11 +33,17 @@ export interface ContentBlock { /** * The collection query used to read guideline rows by slug. + * + * Note: no `context` is set here on purpose. The `wp_knowledge` (postType) + * entity already fetches with `context: 'edit'` via its `baseURLParams`, so the + * response includes raw field values. Passing `context: 'edit'` in the query + * instead would store rows under the `edit` cache bucket, where + * `editEntityRecord`/`getRawEntityRecord` (which read the `default` bucket) + * can't find them — breaking edits of rows loaded after a page refresh. */ export interface GuidelineQuery { slug: string[]; status: string[]; - context: string; per_page: number; } diff --git a/test/e2e/specs/admin/guidelines.spec.js b/test/e2e/specs/admin/guidelines.spec.js index 0fd15b29fae5b1..8701827024945d 100644 --- a/test/e2e/specs/admin/guidelines.spec.js +++ b/test/e2e/specs/admin/guidelines.spec.js @@ -175,6 +175,34 @@ test.describe( 'Guidelines', () => { ).toHaveValue( imagesText ); } ); + test( 'edits a scope guideline after a reload', async ( { + page, + admin, + } ) => { + await admin.visitAdminPage( SETTINGS_PAGE_PATH, GUIDELINES_PAGE_QUERY ); + await expect( getSectionCard( page, 'Copy' ) ).toBeVisible(); + + // Create the row in this session. + await saveSectionGuidelines( page, 'Copy', 'First version.' ); + + // Reload so the row is only available from the collection request + // (edit context via the entity's baseURLParams). Editing it must still + // work — a regression guard for reading the wrong cache bucket. + await page.reload(); + await expect( getSectionCard( page, 'Copy' ) ).toBeVisible(); + + await saveSectionGuidelines( page, 'Copy', 'Second version.' ); + + await page.reload(); + const copyCard = getSectionCard( page, 'Copy' ); + await copyCard + .getByRole( 'button', { name: 'Copy', exact: true } ) + .click(); + await expect( + copyCard.getByRole( 'textbox', { name: 'Copy guidelines' } ) + ).toHaveValue( 'Second version.' ); + } ); + test( 'clears a scope guideline', async ( { page, admin } ) => { await admin.visitAdminPage( SETTINGS_PAGE_PATH, GUIDELINES_PAGE_QUERY ); await expect( getSectionCard( page, 'Copy' ) ).toBeVisible(); From 64b8a4023b2517bc1f3c99f566851217c09939cc Mon Sep 17 00:00:00 2001 From: Aagam Shah <aagam.shah@automattic.com> Date: Wed, 17 Jun 2026 12:12:04 +0530 Subject: [PATCH 3/4] Knowledge: Make block slugs injective and enforce slug uniqueness on update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness fixes for the guideline-row identity model. - Block slugs were lossy: deriving `guideline-block-<ns>-<name>` by replacing `/` with `-` collapsed distinct block names such as `foo/bar-baz` and `foo-bar/baz` onto the same slug, so one block's guideline could overwrite another's. Encode the namespace separator as `_` instead — block names match `[a-z0-9-]+/[a-z0-9-]+` and never contain `_`, so the mapping is injective. The canonical block name still lives in the row title. - The reservation guard only checked slug uniqueness on create, so a REST update could repoint an existing row's slug onto an already-used `guideline-` slug, producing duplicate-identity rows. Run the check on update too, excluding the row itself so content-only saves still pass. Adds reservation tests for the update-collision and content-only-update cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- ...s-gutenberg-guideline-reservation-test.php | 60 +++++++++++++++++++ routes/guidelines/data.ts | 11 +++- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/phpunit/experimental/knowledge/class-gutenberg-guideline-reservation-test.php b/phpunit/experimental/knowledge/class-gutenberg-guideline-reservation-test.php index f9a868f1006285..989a1bdd0fbc08 100644 --- a/phpunit/experimental/knowledge/class-gutenberg-guideline-reservation-test.php +++ b/phpunit/experimental/knowledge/class-gutenberg-guideline-reservation-test.php @@ -95,6 +95,66 @@ public function test_duplicate_prefixed_slug_rejected() { $this->assertSame( 'rest_knowledge_slug_exists', $second->get_data()['code'] ); } + /** + * An update may not repoint a row's slug onto a `guideline-` slug already + * owned by a different row. + */ + public function test_update_to_existing_slug_rejected() { + wp_set_current_user( self::$admin_id ); + + $this->assertSame( + 201, + $this->create_row( + array( + 'slug' => 'guideline-site', + 'content' => 'A.', + 'status' => 'publish', + ) + )->get_status() + ); + $second = $this->create_row( + array( + 'slug' => 'guideline-images', + 'content' => 'B.', + 'status' => 'publish', + ) + ); + $this->assertSame( 201, $second->get_status() ); + $second_id = $second->get_data()['id']; + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge/' . $second_id ); + $request->set_body_params( array( 'slug' => 'guideline-site' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 409, $response->get_status() ); + $this->assertSame( 'rest_knowledge_slug_exists', $response->get_data()['code'] ); + } + + /** + * A content-only update of an existing row is not falsely rejected by the + * uniqueness guard (the row excludes itself). + */ + public function test_content_only_update_succeeds() { + wp_set_current_user( self::$admin_id ); + + $created = $this->create_row( + array( + 'slug' => 'guideline-copy', + 'content' => 'First.', + 'status' => 'publish', + ) + ); + $this->assertSame( 201, $created->get_status() ); + $id = $created->get_data()['id']; + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge/' . $id ); + $request->set_body_params( array( 'content' => 'Second.' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'Second.', get_post( $id )->post_content ); + } + /** * Registry scope titles are re-stamped from `wp_guideline_scopes()`, * ignoring any client-provided title. diff --git a/routes/guidelines/data.ts b/routes/guidelines/data.ts index 5538de94c03e4f..26cbb2b3df1002 100644 --- a/routes/guidelines/data.ts +++ b/routes/guidelines/data.ts @@ -41,12 +41,17 @@ export function scopeSlug( scope: string ): string { } /** - * The slug for a block guideline row, e.g. `guideline-block-core-paragraph`. - * Forward-only and unambiguous; the canonical block name lives in the title. + * The slug for a block guideline row, e.g. `guideline-block-core_paragraph`. + * + * The namespace separator is encoded as `_` rather than `-`. Block names are + * `<namespace>/<name>` where both parts match `[a-z0-9-]+` (never `_`), so `_` + * keeps the mapping injective; using `-` would collapse distinct block names + * such as `foo/bar-baz` and `foo-bar/baz` onto the same slug, overwriting one + * guideline with the other. The canonical block name still lives in the title. * @param blockName Exact block name (e.g. `core/paragraph`). */ export function blockSlug( blockName: string ): string { - return `${ BLOCK_PREFIX }${ blockName.replace( '/', '-' ) }`; + return `${ BLOCK_PREFIX }${ blockName.replace( '/', '_' ) }`; } /** From 18e953e5f80f581a922698c99f12d9d761811e3a Mon Sep 17 00:00:00 2001 From: Aagam Shah <aagam.shah@automattic.com> Date: Wed, 17 Jun 2026 15:22:18 +0530 Subject: [PATCH 4/4] Knowledge: Sync package-lock.json with guidelines route dependencies The guidelines route's package.json dropped @wordpress/api-fetch and @wordpress/date and added @wordpress/core-data; regenerate the lockfile so the check-local-changes CI gate passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- package-lock.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 96909d2498e72e..15b44d2e4fcfd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54272,15 +54272,14 @@ "version": "1.0.0", "dependencies": { "@wordpress/admin-ui": "file:../../packages/admin-ui", - "@wordpress/api-fetch": "file:../../packages/api-fetch", "@wordpress/base-styles": "file:../../packages/base-styles", "@wordpress/blob": "file:../../packages/blob", "@wordpress/block-library": "file:../../packages/block-library", "@wordpress/blocks": "file:../../packages/blocks", "@wordpress/components": "file:../../packages/components", + "@wordpress/core-data": "file:../../packages/core-data", "@wordpress/data": "file:../../packages/data", "@wordpress/dataviews": "file:../../packages/dataviews", - "@wordpress/date": "file:../../packages/date", "@wordpress/element": "file:../../packages/element", "@wordpress/i18n": "file:../../packages/i18n", "@wordpress/icons": "file:../../packages/icons",