diff --git a/plugins/internal-comments/AGENTS.md b/plugins/internal-comments/AGENTS.md index e83c5c6..96df771 100644 --- a/plugins/internal-comments/AGENTS.md +++ b/plugins/internal-comments/AGENTS.md @@ -66,7 +66,7 @@ Both run PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI run 4. **Add-ons.** An add-on extends a base plugin only through the base plugin's documented hooks. It reads a template-built base plugin's slug, name and version only through that plugin's `{prefix}_get_plugin_*()` getters, from `plugins_loaded` on, since the add-on may load first, and behind `function_exists()`, since a base plugin below its floors or with a partial build may stay active without defining them. 5. **Request reads.** An integer or boolean is read as `filter_var( wp_unslash( $_POST['key'] ?? '' ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? $default` (`FILTER_VALIDATE_BOOL` for a boolean, where an absent key reads false, as an unchecked checkbox posts nothing), and a string through an `is_string()` guard and the field's sanitizer; never `filter_input()`, the `default` filter option or a cast on request input. A REST route declares each parameter's type and sanitizer in its `args`. 6. **Options and data.** A setting is read through v1's API in `includes/settings.php`, whose validation is each setting's one typed read and whose reads apply v1's filters, safeguards excepted. v1's settings keep their `dws-internal-comments_` rows and its comments their `dws_internal` type and `dws_ic_is_private` and `_dws_ic_` meta; anything new lives under `dws_ic_`, or `_dws_ic_` for hidden meta. The footprint covers those prefixes and v1's other rows by name. -7. **Internal status.** An internal comment is stored under the `dws_internal` status, which keeps it out of WordPress's queries, counts, feeds and REST API without the plugin's help, so no code stores one under another status, and a query that lists them names the status. `Comments` puts back any that WordPress or another plugin moves, and until the migration has moved the comments v1 stored approved, it leaves the type out of every comment query and feed that names neither it nor the status. Every check of what a user may do with one goes through the `see_`, `edit_` and `trash_dws_internal_comment` meta capabilities. +7. **Internal status.** An internal comment is stored under the `dws_internal` status, which keeps it out of WordPress's queries, counts, feeds and REST API without the plugin's help, so no code stores one under another status, and a query that lists them names the status. `Comments` puts back any that WordPress or another plugin moves, announcing the move as a status change after WordPress announces its own, so caches that follow status changes, such as WooCommerce's comment counts, end where the comment is; the migration's bulk move resets WooCommerce's counts instead. Until the migration has moved the comments v1 stored approved, it leaves the type out of every comment query and feed that names neither it nor the status. Every check of what a user may do with one goes through the `see_`, `edit_` and `trash_dws_internal_comment` meta capabilities. 8. **Order storage.** Order code takes ids WooCommerce resolves in either storage, through `dws_ic_get_post_type()` and `edit_post`, which WooCommerce maps for orders, and WooCommerce's screen ids and list hooks, never the posts table; every Integration row whose outcome the order storage can change runs on all four storages through `OrderStorage`. 9. **v1 hooks.** A hook v1 fired keeps its v1 literal, fired first through `apply_filters_deprecated()` or `do_action_deprecated()` with `'2.0.0'` and the v2 name, then the v2 name. 10. **Safeguards** live in `includes/safeguards.php`, call only WordPress and that file, and relax only through their own filter, through `dws_ic_is_relaxed()` (the template's recipes). @@ -87,8 +87,9 @@ Both run PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI run - Integration and end to end share the tests site, so a row that changes shared data in a separate process flushes the parent's object cache. - The end-to-end suite runs on the order tables only, as the tests site stores orders; the Integration rows cover the posts storage. - A site that returns to 1.2.4 after the upgrade stores its new internal comments approved, and the migration that moved v1's comments does not run again on the next upgrade, so those comments stay public until moved by hand. -- Core's comment edit screen shows author, date and status fields for an internal comment, which `Comments` keeps as stored. -- When a private comment is deleted permanently, its replies become private under their own authors, who then also see the replies beneath them that the deleted comment's privacy hid from them. -- Core's Comments screen trashes or deletes an internal comment for any user who can edit it, because core checks only `edit_comment` there and offers no hook to refuse; the meta box's route and core's REST API check the trash capability. +- Core's comment edit screen hides an internal comment's author, status and date through a style on core's element ids, so a core release that renames them shows those fields again, and saving still keeps them as stored. +- A role given the Comments screen's list capability without `see_private_dws_internal_comments`, or without access to edit every post, can see a page of internal comments run short of its size, and view counts above the rows listed, because the comments it may not see are dropped after the query, which cannot follow a private comment's chain of owners. +- A comment turns private only before anyone replies to it, so every reply under a private comment comes from a user who could see it; code that adds the flag with `add_comment_meta()` where none is stored passes neither this rule nor the author rule, which keeps imports of private comments working. A thread that 1.2.4 made private after others had replied still loses part of its privacy when its first comment is deleted permanently: the replies become private under their own authors, who then also see the replies beneath them that the deleted comment's privacy hid from them. +- Core's comment edit screen opens for an internal comment only to a user who can both edit and trash it, because core trashes, deletes, restores and moderates on the same `edit_comment` check; a role that can only edit edits in the meta box. - The release zip carries the TypeScript and SCSS sources beside the built files. - The bundled GitHub updater ships inert, since the entry file has no `Update URI`, and wordpress.org's reviewers may ask to remove it. diff --git a/plugins/internal-comments/changelog/comment-edit-screen b/plugins/internal-comments/changelog/comment-edit-screen new file mode 100644 index 0000000..494ca65 --- /dev/null +++ b/plugins/internal-comments/changelog/comment-edit-screen @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +WordPress's comment edit screen hides an internal comment's author, status and date, which saving keeps as stored. diff --git a/plugins/internal-comments/changelog/comments-screen-trash-count b/plugins/internal-comments/changelog/comments-screen-trash-count new file mode 100644 index 0000000..5455f29 --- /dev/null +++ b/plugins/internal-comments/changelog/comments-screen-trash-count @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +The Comments screen's Trash count leaves out the internal comments its list hides from users who may not list them. diff --git a/plugins/internal-comments/changelog/core-comment-actions b/plugins/internal-comments/changelog/core-comment-actions new file mode 100644 index 0000000..27d33b9 --- /dev/null +++ b/plugins/internal-comments/changelog/core-comment-actions @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +WordPress's comment edit screen, Comments screen actions and XML-RPC need both the capability to edit an internal comment and the capability to trash it, since WordPress trashes, deletes and moderates on the same check. diff --git a/plugins/internal-comments/changelog/private-before-replies b/plugins/internal-comments/changelog/private-before-replies new file mode 100644 index 0000000..ac1343c --- /dev/null +++ b/plugins/internal-comments/changelog/private-before-replies @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +A comment can be made private only before anyone replies to it; once replied to, a private comment can still be made public. diff --git a/plugins/internal-comments/changelog/woocommerce-comment-counts b/plugins/internal-comments/changelog/woocommerce-comment-counts new file mode 100644 index 0000000..24d4cdc --- /dev/null +++ b/plugins/internal-comments/changelog/woocommerce-comment-counts @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +WooCommerce's comment counts stay right when an internal comment is restored, approved, held or marked spam, and after the background move of v1's comments. diff --git a/plugins/internal-comments/docs/ARCHITECTURE.md b/plugins/internal-comments/docs/ARCHITECTURE.md index 691403c..cbda4d3 100644 --- a/plugins/internal-comments/docs/ARCHITECTURE.md +++ b/plugins/internal-comments/docs/ARCHITECTURE.md @@ -34,8 +34,8 @@ WooCommerce is optional: without it, every feature but the order screens and the ## Data -- **Internal comments** are comments of v1's `dws_internal` type, stored under the `dws_internal` status. WordPress's comment queries, counts, feeds and REST API include only the statuses they name, so they leave internal comments out without the plugin's help, active or not. `Comments` puts back under the status any internal comment that WordPress or another plugin stores, approves, holds, marks spam or restores under another, and keeps every field but the text as stored when one is updated. Until the migration has moved the comments v1 stored approved, it also leaves their type out of every comment query and feed that names neither the type nor the status. A post's comments on a trashed post are read from what WordPress recorded when it trashed the post. -- **Comment meta** keeps v1's keys: `dws_ic_is_private`, `yes` on a comment marked private, which only its author changes; and, per change to a comment's text, `_dws_ic_change_id` with `_dws_ic_previous_content_{id}`, `_dws_ic_changed_by_{id}` and `_dws_ic_changed_time_{id}`. +- **Internal comments** are comments of v1's `dws_internal` type, stored under the `dws_internal` status. WordPress's comment queries, counts, feeds and REST API include only the statuses they name, so they leave internal comments out without the plugin's help, active or not. `Comments` puts back under the status any internal comment that WordPress or another plugin stores, approves, holds, marks spam or restores under another, announcing each move back through `wp_transition_comment_status()` after WordPress's own announcement so status-following caches such as WooCommerce's comment counts stay right, and keeps every field but the text as stored when one is updated. Until the migration has moved the comments v1 stored approved, it also leaves their type out of every comment query and feed that names neither the type nor the status. A post's comments on a trashed post are read from what WordPress recorded when it trashed the post. +- **Comment meta** keeps v1's keys: `dws_ic_is_private`, `yes` on a comment marked private, which only its author changes, and only before anyone replies when it turns private; and, per change to a comment's text, `_dws_ic_change_id` with `_dws_ic_previous_content_{id}`, `_dws_ic_changed_by_{id}` and `_dws_ic_changed_time_{id}`. - **Options** keep v1's two rows, `dws-internal-comments_general` with `post-types` and `enable-private-comments`, and `dws-internal-comments_plugin` with `remove-data-uninstall`, as constants in `includes/options.php`. Code reads them through v1's API in `includes/settings.php`, which validates each stored value, falls back to v1's default, and applies v1's filters and then `dws_ic/settings/validated_value`. - **Migrations** are the append-only steps `dws_ic_get_migration_steps()` returns. The framework's runner runs each pending step once per site, behind a lock, and records the revision reached in `dws_ic_revision`. The first step stores v1's default rows where a site, v1 or new, has none. The second is batched, so it runs only from the runner's scheduled hook: it walks the comments table by id window, moves the internal comments v1 stored approved, pending or as spam to the status and recounts their posts, keeping its position in `dws_ic_v1_comments_cursor` until the walk ends. A step converges from any partial state and throws when a write fails, so a failed step runs again on a later request. - **The footprint**, `dws_ic_get_footprint()`, names every kind of data the plugin stores: the `dws_ic_` and `dws-internal-comments_` option prefixes, the `dws_internal` comments with their meta, v1's version and notice rows, user meta, network options, the capabilities, scheduled hooks and Action Scheduler groups. Freemius's `fs_` rows from v1 stay, since they belong to Freemius. diff --git a/plugins/internal-comments/includes/comments.php b/plugins/internal-comments/includes/comments.php index e2d9d82..fa4087a 100644 --- a/plugins/internal-comments/includes/comments.php +++ b/plugins/internal-comments/includes/comments.php @@ -311,3 +311,26 @@ function dws_ic_exclude_internal_type( $excluded ): array { function dws_ic_query_names_internal_type( array $query_vars ): bool { return in_array( DWS_IC_COMMENT_TYPE, array_merge( (array) ( $query_vars['type'] ?? array() ), (array) ( $query_vars['type__in'] ?? array() ) ), true ); } + +/** + * Returns whether any internal comment replies to a comment, whatever its status. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param int $comment_id The comment's id. + * + * @return bool + */ +function dws_ic_has_replies( int $comment_id ): bool { + $replies = get_comments( + array( + 'parent' => $comment_id, + 'type' => DWS_IC_COMMENT_TYPE, + 'status' => 'any', + 'count' => true, + ) + ); + + return is_int( $replies ) && 0 < $replies; +} diff --git a/plugins/internal-comments/includes/migrations.php b/plugins/internal-comments/includes/migrations.php index 1efe664..49c1bed 100644 --- a/plugins/internal-comments/includes/migrations.php +++ b/plugins/internal-comments/includes/migrations.php @@ -9,6 +9,7 @@ */ use DeepWebSolutions\InternalComments\Scoped\DeepWebSolutions\Framework\Core\Migration\BatchedMigrationStepInterface; +use DeepWebSolutions\InternalComments\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; defined( 'ABSPATH' ) || exit; @@ -22,6 +23,18 @@ */ const DWS_IC_REVISION_V1_COMMENTS_MOVED = 2; +/** + * Returns whether the migration has yet to move the comments v1 stored approved under the internal status. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @return bool + */ +function dws_ic_is_v1_move_pending(): bool { + return MigrationRunner::get_revision( 'dws_ic_revision' ) < DWS_IC_REVISION_V1_COMMENTS_MOVED; +} + /** * Returns the migration steps, append-only, where index 0 brings the data to revision 1. * @@ -108,6 +121,14 @@ function dws_ic_convert_v1_comments(): bool { } } + // Counts cached while the move is pending leave internal comments out, and WooCommerce adjusts its own only on announced status changes. + if ( array() !== $ids || $end >= $last ) { + wp_cache_delete( 'comments-0', 'counts' ); + if ( class_exists( 'WC_Comments' ) ) { + WC_Comments::delete_comments_count_cache(); + } + } + if ( $end >= $last ) { delete_option( 'dws_ic_v1_comments_cursor' ); diff --git a/plugins/internal-comments/readme.txt b/plugins/internal-comments/readme.txt index c090d34..251b716 100644 --- a/plugins/internal-comments/readme.txt +++ b/plugins/internal-comments/readme.txt @@ -81,7 +81,7 @@ You can open a community support ticket here at [wordpress.org](https://wordpres == Upgrade Notice == = 2.0.0 = -Requires PHP 8.5 and WordPress 7.1. Internal comments move to a status of their own in the background, so going back to 1.2.4 hides them. Reading one also needs access to edit its post. Hooks move to dws_ic/ names; v1's AJAX actions, accessors and framework hooks are removed. +Requires PHP 8.5 and WordPress 7.1. Internal comments move to a status of their own, so 1.2.4 no longer shows them, and any it posts stay public after a later update. Reading one needs access to edit its post. Hooks move to dws_ic/; v1's AJAX actions, accessors and framework hooks are removed. == Changelog == diff --git a/plugins/internal-comments/src/AdminScreens.php b/plugins/internal-comments/src/AdminScreens.php index f6036d8..53c47c7 100644 --- a/plugins/internal-comments/src/AdminScreens.php +++ b/plugins/internal-comments/src/AdminScreens.php @@ -48,9 +48,12 @@ public function register_hooks(): void { add_action( "manage_{$orders_screen}_custom_column", array( $this, 'render_order_cell' ), 10, 2 ); } add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_list_assets' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_comment_screen_style' ) ); add_action( 'admin_menu', array( $this, 'add_comments_view' ) ); add_filter( 'admin_comment_types_dropdown', array( $this, 'add_comment_type' ) ); add_filter( 'comments_list_table_query_args', array( $this, 'filter_list_table_args' ) ); + // After WooCommerce, which answers with counts of its own at the default priority. + add_filter( 'wp_count_comments', array( $this, 'count_listed_comments' ), 20, 2 ); add_filter( 'the_comments', array( $this, 'filter_list_table_comments' ), 10, 2 ); add_filter( 'comment_row_actions', array( $this, 'filter_row_actions' ), \PHP_INT_MAX, 2 ); add_filter( 'bulk_actions-edit-comments', array( $this, 'filter_bulk_actions' ), \PHP_INT_MAX ); @@ -378,6 +381,97 @@ public function filter_bulk_actions( array $actions ): array { return DWS_IC_COMMENT_TYPE === $type ? \array_intersect_key( $actions, \array_flip( array( 'trash', 'untrash', 'delete' ) ) ) : $actions; } + /** + * Returns the Comments screen's counts with a Trash count that leaves out the trashed internal comments its list leaves out for a user who may not list them. + * + * WordPress and WooCommerce cache the counts for every user, so the plugin answers with an adjusted copy and leaves their caches alone. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param mixed $count The counts another filter answered with, or an empty array. + * @param int $post_id The post whose comments are counted, or 0 for every post. + * + * @return mixed + */ + public function count_listed_comments( mixed $count, int $post_id ): mixed { + $screen = \function_exists( 'get_current_screen' ) ? get_current_screen() : null; + // While v1's comments are being moved, every count already leaves the internal type out. + if ( 'edit-comments' !== $screen?->id || current_user_can( DWS_IC_CAP_SEE_TABLE ) || dws_ic_is_v1_move_pending() ) { + return $count; + } + + if ( ! \is_object( $count ) ) { + remove_filter( 'wp_count_comments', array( $this, 'count_listed_comments' ), 20 ); + try { + $count = wp_count_comments( $post_id ); + } finally { + add_filter( 'wp_count_comments', array( $this, 'count_listed_comments' ), 20, 2 ); + } + } + + // The count names no type, so the exclusions other plugins apply to such queries, as WooCommerce's of comments on products, apply to it as they did to the count it replaces. + add_filter( 'comments_clauses', array( $this, 'leave_out_internal_type' ) ); + try { + $listed = get_comments( + array( + 'post_id' => $post_id, + 'status' => 'trash', + 'count' => true, + ) + ); + } finally { + remove_filter( 'comments_clauses', array( $this, 'leave_out_internal_type' ) ); + } + + $counts = \get_object_vars( $count ); + $counts['trash'] = \is_int( $listed ) ? $listed : 0; + + return (object) $counts; + } + + /** + * Returns a comment query's clauses with the internal type left out of its WHERE clause, for a count that must name no type. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @global \wpdb $wpdb The WordPress database connection. + * + * @param array $clauses The query's clauses. + * + * @return array + */ + public function leave_out_internal_type( array $clauses ): array { + global $wpdb; + + $clauses['where'] = ( $clauses['where'] ?? '' ) . ( $wpdb->prepare( ' AND %i.comment_type <> %s', $wpdb->comments, DWS_IC_COMMENT_TYPE ) ?? '' ); + + return $clauses; + } + + /** + * Hides the author, status and date of an internal comment on WordPress's comment edit screen, since saving keeps them as stored. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $hook_suffix The admin page's hook suffix. + * + * @return void + */ + public function enqueue_comment_screen_style( string $hook_suffix ): void { + // The comment screen loads its comment before it prints the page's head. + $comment = $GLOBALS['comment'] ?? null; + if ( 'comment.php' !== $hook_suffix || ! $comment instanceof \WP_Comment || DWS_IC_COMMENT_TYPE !== $comment->comment_type ) { + return; + } + + wp_register_style( Plugin::SLUG . '-comment-screen', false ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- A style without a file has no version to bust. + wp_enqueue_style( Plugin::SLUG . '-comment-screen' ); + wp_add_inline_style( Plugin::SLUG . '-comment-screen', '#namediv, #comment-status, .misc-pub-curtime { display: none; }' ); + } + /** * Adds the revision history meta box to the edit screen of an internal comment the user can see. * @@ -460,7 +554,7 @@ public function add_privacy_field( string $html, \WP_Comment $comment ): string return $html; } - $can_choose = get_current_user_id() === (int) $comment->user_id && current_user_can( DWS_IC_CAP_INSERT_PRIVATE ) && true !== dws_ic_is_private_comment( (int) $comment->comment_parent ); + $can_choose = get_current_user_id() === (int) $comment->user_id && current_user_can( DWS_IC_CAP_INSERT_PRIVATE ) && true !== dws_ic_is_private_comment( (int) $comment->comment_parent ) && ( $is_private || ! dws_ic_has_replies( (int) $comment->comment_ID ) ); \ob_start(); ?>
diff --git a/plugins/internal-comments/src/Comments.php b/plugins/internal-comments/src/Comments.php index 5b1f0cc..ce862f1 100644 --- a/plugins/internal-comments/src/Comments.php +++ b/plugins/internal-comments/src/Comments.php @@ -3,7 +3,6 @@ namespace DeepWebSolutions\InternalComments; use DeepWebSolutions\InternalComments\Scoped\DeepWebSolutions\Framework\Core\ComponentInterface; -use DeepWebSolutions\InternalComments\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; \defined( 'ABSPATH' ) || exit; @@ -26,13 +25,15 @@ final class Comments implements ComponentInterface { */ #[\Override] public function register_hooks(): void { - add_action( 'wp_insert_comment', array( $this, 'keep_internal' ) ); + add_action( 'wp_insert_comment', array( $this, 'keep_inserted_internal' ) ); // Before wp_new_comment_notify_postauthor(), which wp_set_comment_status() hooks to mail an approved comment to the post's author. add_action( 'wp_set_comment_status', array( $this, 'keep_internal' ), 1 ); + // After every listener of WordPress's own announcement of the change the plugin undoes, such as WooCommerce's comment counts. + add_action( 'transition_comment_status', array( $this, 'announce_kept_internal' ), \PHP_INT_MAX, 3 ); add_action( 'untrash_comment', array( $this, 'prepare_restore' ) ); add_action( 'untrashed_post_comments', array( $this, 'keep_post_comments_internal' ) ); add_filter( 'wp_update_comment_data', array( $this, 'filter_update_data' ), \PHP_INT_MAX, 2 ); - add_filter( 'update_comment_metadata', array( $this, 'guard_privacy' ), 10, 3 ); + add_filter( 'update_comment_metadata', array( $this, 'guard_privacy' ), 10, 4 ); add_filter( 'delete_comment_metadata', array( $this, 'guard_privacy' ), 10, 3 ); add_action( 'delete_comment', array( $this, 'keep_replies_private' ) ); add_action( 'pre_get_comments', array( $this, 'hide_unmoved_comments' ) ); @@ -44,28 +45,62 @@ public function register_hooks(): void { // region HOOKS /** - * Puts an internal comment stored under another status that would show it, such as one imported from v1, back under the internal status. + * Puts an internal comment whose status WordPress changed back under the internal status, before WordPress announces the change. * * @since 2.0.0 * @version 2.0.0 * - * @global \wpdb $wpdb The WordPress database connection. - * * @param int|string $comment_id The comment's id. * * @return void */ public function keep_internal( int|string $comment_id ): void { - global $wpdb; + $this->put_back( $comment_id ); + } + + /** + * Puts an internal comment stored under another status that would show it, such as one imported from v1, back under the internal status, and announces the move as a status change, since WordPress announces none for a new comment. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param int|string $comment_id The comment's id. + * + * @return void + */ + public function keep_inserted_internal( int|string $comment_id ): void { + $replaced = $this->put_back( $comment_id ); + if ( null === $replaced ) { + return; + } $comment = dws_ic_get_comment( $comment_id ); - if ( null === $comment || DWS_IC_COMMENT_TYPE !== $comment->comment_type || \in_array( $comment->comment_approved, array( DWS_IC_COMMENT_STATUS, 'trash', 'post-trashed' ), true ) ) { + if ( null !== $comment ) { + wp_transition_comment_status( DWS_IC_COMMENT_STATUS, $replaced, $comment ); + } + } + + /** + * Announces the move of an internal comment back under the internal status after WordPress announces the change the plugin undid, so whatever follows status changes, such as WooCommerce's comment counts, ends where the comment is. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param int|string $new_status The status WordPress announced. + * @param int|string $old_status The status the comment had before. + * @param mixed $comment The comment. + * + * @return void + */ + public function announce_kept_internal( int|string $new_status, int|string $old_status, mixed $comment ): void { + if ( ! $comment instanceof \WP_Comment || DWS_IC_COMMENT_TYPE !== $comment->comment_type || DWS_IC_COMMENT_STATUS === $new_status ) { return; } - $wpdb->update( $wpdb->comments, array( 'comment_approved' => DWS_IC_COMMENT_STATUS ), array( 'comment_ID' => (int) $comment->comment_ID ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- wp_set_comment_status() refuses a status of its own, and the cache is cleaned below. - clean_comment_cache( (int) $comment->comment_ID ); - wp_update_comment_count( (int) $comment->comment_post_ID ); + $stored = dws_ic_get_comment( (int) $comment->comment_ID ); + if ( null !== $stored && DWS_IC_COMMENT_STATUS === $stored->comment_approved ) { + wp_transition_comment_status( DWS_IC_COMMENT_STATUS, (string) $new_status, $stored ); + } } /** @@ -104,7 +139,7 @@ public function keep_post_comments_internal( int $post_id ): void { ) ); foreach ( \is_array( $comments ) ? $comments : array() as $comment_id ) { - $this->keep_internal( $comment_id ); + $this->put_back( $comment_id ); } } @@ -172,7 +207,7 @@ public function keep_replies_private( int|string $comment_id ): void { update_comment_meta( (int) $reply, DWS_IC_META_PRIVATE, 'yes' ); } } finally { - add_filter( 'update_comment_metadata', array( $this, 'guard_privacy' ), 10, 3 ); + add_filter( 'update_comment_metadata', array( $this, 'guard_privacy' ), 10, 4 ); } } @@ -187,7 +222,7 @@ public function keep_replies_private( int|string $comment_id ): void { * @return void */ public function hide_unmoved_comments( \WP_Comment_Query $query ): void { - if ( ! $this->is_move_pending() || dws_ic_query_names_internal_type( $query->query_vars ) || \in_array( DWS_IC_COMMENT_STATUS, (array) ( $query->query_vars['status'] ?? array() ), true ) ) { + if ( ! dws_ic_is_v1_move_pending() || dws_ic_query_names_internal_type( $query->query_vars ) || \in_array( DWS_IC_COMMENT_STATUS, (array) ( $query->query_vars['status'] ?? array() ), true ) ) { return; } @@ -209,11 +244,11 @@ public function hide_unmoved_comments( \WP_Comment_Query $query ): void { public function hide_unmoved_feed_comments( string $where ): string { global $wpdb; - return $this->is_move_pending() ? $where . ( $wpdb->prepare( ' AND %i.comment_type <> %s', $wpdb->comments, DWS_IC_COMMENT_TYPE ) ?? '' ) : $where; + return dws_ic_is_v1_move_pending() ? $where . ( $wpdb->prepare( ' AND %i.comment_type <> %s', $wpdb->comments, DWS_IC_COMMENT_TYPE ) ?? '' ) : $where; } /** - * Refuses a change to whether an internal comment is private by a signed-in user other than its author. + * Refuses a change to whether an internal comment is private by a signed-in user other than its author, and turning one private once anyone has replied to it. * * @since 2.0.0 * @version 2.0.0 @@ -221,11 +256,22 @@ public function hide_unmoved_feed_comments( string $where ): string { * @param mixed $check Null to let the change proceed, or the result that replaces it. * @param int $comment_id The comment's id. * @param string $meta_key The meta key changed. + * @param mixed $meta_value The value written, or null where the meta is deleted. * * @return mixed */ - public function guard_privacy( mixed $check, int $comment_id, string $meta_key ): mixed { - if ( DWS_IC_META_PRIVATE !== $meta_key || 0 === get_current_user_id() ) { + public function guard_privacy( mixed $check, int $comment_id, string $meta_key, mixed $meta_value = null ): mixed { + if ( DWS_IC_META_PRIVATE !== $meta_key ) { + return $check; + } + + // A comment turns private only before anyone replies, since a reply's author would otherwise own the private thread once the comment above is deleted. + $turns_private = true === filter_var( $meta_value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ) && true !== filter_var( get_comment_meta( $comment_id, DWS_IC_META_PRIVATE, true ), FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ); + if ( $turns_private && dws_ic_has_replies( $comment_id ) ) { + return false; + } + + if ( 0 === get_current_user_id() ) { return $check; } @@ -239,15 +285,30 @@ public function guard_privacy( mixed $check, int $comment_id, string $meta_key ) // region HELPERS /** - * Returns whether the migration has yet to move the comments v1 stored approved under the internal status. + * Puts an internal comment stored under a status that would show it back under the internal status, and returns the status it replaced, or null where it moved nothing. * * @since 2.0.0 * @version 2.0.0 * - * @return bool + * @global \wpdb $wpdb The WordPress database connection. + * + * @param int|string $comment_id The comment's id. + * + * @return string|null */ - protected function is_move_pending(): bool { - return MigrationRunner::get_revision( Plugin::PREFIX . '_revision' ) < DWS_IC_REVISION_V1_COMMENTS_MOVED; + protected function put_back( int|string $comment_id ): ?string { + global $wpdb; + + $comment = dws_ic_get_comment( $comment_id ); + if ( null === $comment || DWS_IC_COMMENT_TYPE !== $comment->comment_type || \in_array( $comment->comment_approved, array( DWS_IC_COMMENT_STATUS, 'trash', 'post-trashed' ), true ) ) { + return null; + } + + $wpdb->update( $wpdb->comments, array( 'comment_approved' => DWS_IC_COMMENT_STATUS ), array( 'comment_ID' => (int) $comment->comment_ID ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- wp_set_comment_status() refuses a status of its own, and the cache is cleaned below. + clean_comment_cache( (int) $comment->comment_ID ); + wp_update_comment_count( (int) $comment->comment_post_ID ); + + return $comment->comment_approved; } // endregion diff --git a/plugins/internal-comments/src/Permissions.php b/plugins/internal-comments/src/Permissions.php index fa11b52..f111f22 100644 --- a/plugins/internal-comments/src/Permissions.php +++ b/plugins/internal-comments/src/Permissions.php @@ -51,8 +51,9 @@ public function register_hooks(): void { * @return list */ public function map_meta_cap( array $caps, string $cap, int $user_id, array $args ): array { + // Core trashes, deletes, restores and moderates a comment on edit_comment alone, so it asks for both capabilities. if ( 'edit_comment' === $cap ) { - return true === dws_ic_is_internal_comment( $args[0] ?? null ) ? \array_values( map_meta_cap( 'edit_dws_internal_comment', $user_id, ...$args ) ) : $caps; + return true === dws_ic_is_internal_comment( $args[0] ?? null ) ? \array_values( \array_unique( \array_merge( map_meta_cap( 'edit_dws_internal_comment', $user_id, ...$args ), map_meta_cap( 'trash_dws_internal_comment', $user_id, ...$args ) ) ) ) : $caps; } if ( ! \in_array( $cap, array( 'see_dws_internal_comment', 'edit_dws_internal_comment', 'trash_dws_internal_comment' ), true ) ) { diff --git a/plugins/internal-comments/src/RestRoutes.php b/plugins/internal-comments/src/RestRoutes.php index 1052df6..854146c 100644 --- a/plugins/internal-comments/src/RestRoutes.php +++ b/plugins/internal-comments/src/RestRoutes.php @@ -221,7 +221,7 @@ public function create_comment( \WP_REST_Request $request ): \WP_REST_Response|\ // A comment its author meant to keep private is never stored for every reader instead. $private = true === $request['private']; if ( $private && ! ( dws_ic_are_enabled_private_comments() && current_user_can( DWS_IC_CAP_INSERT_PRIVATE ) ) ) { - return new \WP_Error( 'dws_ic_cannot_be_private', __( 'You cannot post a private internal comment here. Clear the private option to post it for everyone who can see internal comments.', 'internal-comments' ), array( 'status' => 403 ) ); + return new \WP_Error( 'dws_ic_cannot_be_private', __( 'This comment was not posted, because the site\'s settings no longer let you post private internal comments. Clear the private option to post it for everyone who can see internal comments.', 'internal-comments' ), array( 'status' => 403 ) ); } /** diff --git a/plugins/internal-comments/tests/Integration/AdminScreensTest.php b/plugins/internal-comments/tests/Integration/AdminScreensTest.php index e29a770..b48e65a 100644 --- a/plugins/internal-comments/tests/Integration/AdminScreensTest.php +++ b/plugins/internal-comments/tests/Integration/AdminScreensTest.php @@ -246,6 +246,141 @@ public function test_the_edit_screen_of_an_internal_comment_shows_its_revisions_ self::assertStringContainsString( 'name="comment_meta[dws_ic_is_private]"', $author ); self::assertStringNotContainsString( 'name="comment_meta[dws_ic_is_private]"', $other ); self::assertSame( '', $screens->add_privacy_field( '', get_comment( (int) wp_insert_comment( array( 'comment_post_ID' => $this->post ) ) ) ) ); + + // Once someone has replied, the author may still make a private comment public, but not the other way round. + wp_set_current_user( self::$users['administrator'] ); + $private = $this->comment( 'Private', array( DWS_IC_META_PRIVATE => 'yes' ) ); + foreach ( array( $comment, $private ) as $replied_to ) { + dws_ic_create_internal_comment( + array( + 'comment_post_ID' => $this->post, + 'comment_parent' => $replied_to, + ) + ); + } + self::assertStringNotContainsString( 'name="comment_meta[dws_ic_is_private]"', $screens->add_privacy_field( '', get_comment( $comment ) ) ); + self::assertStringContainsString( 'name="comment_meta[dws_ic_is_private]"', $screens->add_privacy_field( '', get_comment( $private ) ) ); + } + + // Saving keeps an internal comment's author, status and date as stored, so the edit screen does not offer them. + public function test_the_comment_edit_screen_hides_the_fields_an_internal_comment_keeps(): void { + $screens = new AdminScreens(); + $styles = static function ( int $comment ) use ( $screens ): string { + $GLOBALS['comment'] = get_comment( $comment ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Stands in for the edit screen's comment. + try { + $screens->enqueue_comment_screen_style( 'comment.php' ); + + return \implode( '', (array) wp_styles()->get_data( 'internal-comments-comment-screen', 'after' ) ); + } finally { + unset( $GLOBALS['comment'] ); + wp_dequeue_style( 'internal-comments-comment-screen' ); + wp_deregister_style( 'internal-comments-comment-screen' ); + } + }; + + $internal = $styles( $this->comment( 'Internal' ) ); + foreach ( array( '#namediv', '#comment-status', '.misc-pub-curtime' ) as $selector ) { + self::assertStringContainsString( $selector, $internal ); + } + self::assertSame( '', $styles( (int) wp_insert_comment( array( 'comment_post_ID' => $this->post ) ) ) ); + } + + // WordPress counts the Comments screen's views apart from its list, and it and WooCommerce cache the counts for every user. + public function test_the_comments_screen_counts_only_the_trashed_comments_the_user_may_list(): void { + $internal = $this->comment( 'Trashed' ); + $public = (int) wp_insert_comment( array( 'comment_post_ID' => $this->post ) ); + wp_trash_comment( $internal ); + wp_trash_comment( $public ); + $GLOBALS['current_screen'] = \WP_Screen::get( 'edit-comments' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Sets the screen without firing current_screen, which other plugins hook with admin-only code. + + $counts = static function (): array { + wp_set_current_user( self::$users['editor'] ); + wp_get_current_user()->get_role_caps(); // An earlier row can leave the editor current with capabilities its role has since lost. + $editor = (int) wp_count_comments()->trash; + wp_set_current_user( self::$users['administrator'] ); + + return array( $editor, (int) wp_count_comments()->trash ); + }; + $woocommerce = has_filter( 'wp_count_comments', array( 'WC_Comments', 'wp_count_comments' ) ); + \WC_Comments::delete_comments_count_cache(); // Earlier tests leave WooCommerce's incremental counts behind. + + try { + list( $editor, $administrator ) = $counts(); + remove_filter( 'wp_count_comments', array( 'WC_Comments', 'wp_count_comments' ) ); + wp_cache_delete( 'comments-0', 'counts' ); + list( $core_editor, $core_administrator ) = $counts(); + $cached = wp_cache_get( 'comments-0', 'counts' ); + } finally { + if ( \is_int( $woocommerce ) ) { + add_filter( 'wp_count_comments', array( 'WC_Comments', 'wp_count_comments' ), $woocommerce, 2 ); + } + unset( $GLOBALS['current_screen'] ); + } + + self::assertIsInt( $woocommerce ); + self::assertSame( $administrator - 1, $editor ); + self::assertSame( $core_administrator - 1, $core_editor ); + self::assertIsObject( $cached ); + self::assertSame( $core_administrator, (int) $cached->trash ); + } + + // WooCommerce leaves comments on products out of every comment query that names no type, the counts included. + public function test_the_comments_screen_counts_trashed_comments_as_woocommerce_lists_them(): void { + $product = (int) wp_insert_post( + array( + 'post_type' => 'product', + 'post_title' => 'Product', + 'post_status' => 'publish', + ) + ); + wp_trash_comment( (int) dws_ic_create_internal_comment( array( 'comment_post_ID' => $product ), get_user_by( 'id', self::$users['administrator'] ) ?: null ) ); + wp_trash_comment( (int) wp_insert_comment( array( 'comment_post_ID' => $this->post ) ) ); + $GLOBALS['current_screen'] = \WP_Screen::get( 'edit-comments' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Sets the screen without firing current_screen, which other plugins hook with admin-only code. + \WC_Comments::delete_comments_count_cache(); + wp_cache_delete( 'comments-0', 'counts' ); + + try { + wp_set_current_user( self::$users['administrator'] ); + $administrator = (int) wp_count_comments()->trash; + wp_set_current_user( self::$users['editor'] ); + wp_get_current_user()->get_role_caps(); // An earlier row can leave the editor current with capabilities its role has since lost. + $editor = (int) wp_count_comments()->trash; + } finally { + unset( $GLOBALS['current_screen'] ); + wp_delete_post( $product, true ); + \WC_Comments::delete_comments_count_cache(); + wp_cache_delete( 'comments-0', 'counts' ); + } + + self::assertGreaterThan( 0, $administrator ); + self::assertSame( $administrator, $editor ); + } + + // While v1's comments are being moved, the type exclusion already leaves internal comments out of every count. + public function test_the_comments_screen_counts_trashed_comments_once_while_v1s_are_moved(): void { + wp_trash_comment( $this->comment( 'Trashed' ) ); + wp_trash_comment( (int) wp_insert_comment( array( 'comment_post_ID' => $this->post ) ) ); + $revision = get_option( 'dws_ic_revision' ); + update_option( 'dws_ic_revision', 1 ); + $GLOBALS['current_screen'] = \WP_Screen::get( 'edit-comments' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Sets the screen without firing current_screen, which other plugins hook with admin-only code. + \WC_Comments::delete_comments_count_cache(); + wp_cache_delete( 'comments-0', 'counts' ); + + try { + wp_set_current_user( self::$users['administrator'] ); + $administrator = (int) wp_count_comments()->trash; + wp_set_current_user( self::$users['editor'] ); + wp_get_current_user()->get_role_caps(); // An earlier row can leave the editor current with capabilities its role has since lost. + $editor = (int) wp_count_comments()->trash; + } finally { + update_option( 'dws_ic_revision', $revision ); + unset( $GLOBALS['current_screen'] ); + \WC_Comments::delete_comments_count_cache(); + wp_cache_delete( 'comments-0', 'counts' ); + } + + self::assertGreaterThan( 0, $administrator ); + self::assertSame( $administrator, $editor ); } public function test_the_posts_list_loads_the_quick_views_assets(): void { diff --git a/plugins/internal-comments/tests/Integration/DataGuardsTest.php b/plugins/internal-comments/tests/Integration/DataGuardsTest.php index ae9e030..cd05298 100644 --- a/plugins/internal-comments/tests/Integration/DataGuardsTest.php +++ b/plugins/internal-comments/tests/Integration/DataGuardsTest.php @@ -153,6 +153,30 @@ public function test_a_trashed_internal_comment_restores_internal(): void { self::assertSame( '', get_comment_meta( $comment, '_wp_trash_meta_status', true ) ); } + // WooCommerce counts comments from a cache it adjusts on each status change, so moving a comment back under the internal status is announced as one. + public function test_moving_a_comment_back_under_the_internal_status_keeps_woocommerces_counts(): void { + $comment = $this->comment(); + wp_trash_comment( $comment ); + \WC_Comments::delete_comments_count_cache(); + wp_count_comments(); + + wp_untrash_comment( $comment ); + wp_set_comment_status( $comment, 'approve' ); + $imported = (int) wp_insert_comment( + array( + 'comment_post_ID' => $this->post, + 'comment_type' => DWS_IC_COMMENT_TYPE, + 'comment_approved' => '1', + ) + ); + $cached = (array) wp_count_comments(); + \WC_Comments::delete_comments_count_cache(); + + self::assertSame( DWS_IC_COMMENT_STATUS, get_comment( $comment )?->comment_approved ); + self::assertSame( DWS_IC_COMMENT_STATUS, get_comment( $imported )?->comment_approved ); + self::assertSame( (array) wp_count_comments(), $cached ); + } + public function test_a_restored_posts_internal_comments_restore_internal(): void { $comment = $this->comment(); $v1 = $this->comment(); @@ -170,6 +194,32 @@ public function test_a_restored_posts_internal_comments_restore_internal(): void self::assertSame( '1', get_post( $this->post )?->comment_count ); } + // A reply written while its thread was open would otherwise make its author an owner of the private thread once the comment above it is deleted. + public function test_a_comment_becomes_private_only_before_anyone_replies(): void { + wp_set_current_user( $this->admin ); + $reply = fn ( int $replied_to ): int => (int) dws_ic_create_internal_comment( + array( + 'comment_post_ID' => $this->post, + 'comment_parent' => $replied_to, + ) + ); + $replied = $this->comment( 'Replied' ); + $private = $this->comment( 'Private', array( DWS_IC_META_PRIVATE => 'yes' ) ); + $alone = $this->comment( 'Alone' ); + $reply( $replied ); + $reply( $private ); + + foreach ( array( $this->admin, 0 ) as $user ) { + wp_set_current_user( $user ); + self::assertFalse( update_comment_meta( $replied, DWS_IC_META_PRIVATE, 'yes' ) ); + self::assertFalse( dws_ic_is_private_comment( $replied ) ); + } + wp_set_current_user( $this->admin ); + self::assertTrue( update_comment_meta( $private, DWS_IC_META_PRIVATE, 'no' ) ); + self::assertNotFalse( update_comment_meta( $alone, DWS_IC_META_PRIVATE, 'yes' ) ); + self::assertTrue( dws_ic_is_private_comment( $alone ) ); + } + public function test_only_a_comments_author_changes_whether_it_is_private(): void { $comment = $this->comment( 'Private', array( DWS_IC_META_PRIVATE => 'yes' ) ); diff --git a/plugins/internal-comments/tests/Integration/MigrationsTest.php b/plugins/internal-comments/tests/Integration/MigrationsTest.php index e7599a5..b36f56d 100644 --- a/plugins/internal-comments/tests/Integration/MigrationsTest.php +++ b/plugins/internal-comments/tests/Integration/MigrationsTest.php @@ -76,6 +76,39 @@ public function test_the_first_step_throws_when_a_default_row_cannot_be_stored() } } + // The type exclusion leaves internal comments out of the counts cached while the move is pending, so its end has them counted afresh. + public function test_the_end_of_the_move_has_the_comment_counts_counted_afresh(): void { + $post = (int) wp_insert_post( + array( + 'post_title' => 'Counted', + 'post_status' => 'publish', + ) + ); + $trashed = (int) dws_ic_create_internal_comment( array( 'comment_post_ID' => $post ) ); + wp_trash_comment( $trashed ); + $revision = get_option( 'dws_ic_revision' ); + update_option( 'dws_ic_revision', 1 ); + delete_option( 'dws_ic_v1_comments_cursor' ); + \WC_Comments::delete_comments_count_cache(); + wp_cache_delete( 'comments-0', 'counts' ); + + try { + wp_count_comments(); + while ( dws_ic_convert_v1_comments() ) { + continue; + } + } finally { + update_option( 'dws_ic_revision', $revision ); + } + $cached = (array) wp_count_comments(); + \WC_Comments::delete_comments_count_cache(); + wp_cache_delete( 'comments-0', 'counts' ); + $fresh = (array) wp_count_comments(); + wp_delete_post( $post, true ); + + self::assertSame( $fresh, $cached ); + } + public function test_the_second_step_converts_v1s_internal_comments_in_batches_and_recounts_their_posts(): void { global $wpdb; $post = (int) wp_insert_post( @@ -110,6 +143,8 @@ public function test_the_second_step_converts_v1s_internal_comments_in_batches_a clean_post_cache( $post ); self::assertInstanceOf( BatchedMigrationStepInterface::class, dws_ic_get_migration_steps()[1] ?? null ); + \WC_Comments::delete_comments_count_cache(); + wp_count_comments(); // WooCommerce caches the counts before the move, as on a store whose admin screens were open. $batches = 0; while ( dws_ic_convert_v1_comments() ) { self::assertLessThan( 10000, ++$batches ); @@ -122,6 +157,9 @@ public function test_the_second_step_converts_v1s_internal_comments_in_batches_a self::assertSame( '1', get_comment( $note )?->comment_approved ); self::assertSame( '2', get_post( $post )?->comment_count ); self::assertFalse( get_option( 'dws_ic_v1_comments_cursor' ) ); + $cached = (array) wp_count_comments(); + \WC_Comments::delete_comments_count_cache(); + self::assertSame( (array) wp_count_comments(), $cached ); wp_delete_post( $post, true ); } diff --git a/plugins/internal-comments/tests/Integration/PermissionsTest.php b/plugins/internal-comments/tests/Integration/PermissionsTest.php index 3fc3623..eea0e85 100644 --- a/plugins/internal-comments/tests/Integration/PermissionsTest.php +++ b/plugins/internal-comments/tests/Integration/PermissionsTest.php @@ -92,6 +92,29 @@ public function test_editing_an_internal_comment_through_core_needs_the_edit_cap self::assertTrue( user_can( self::$users['administrator'], 'edit_comment', $internal ) ); } + // Core trashes, deletes, restores and moderates a comment on edit_comment alone, so it asks for both capabilities, while the meta box asks for each on its own. + public function test_core_changes_to_an_internal_comment_need_the_edit_and_the_trash_capability(): void { + $internal = $this->comment( $this->post( 'author' ), 'author' ); + $editor = get_role( 'editor' ); + $editor?->add_cap( DWS_IC_CAP_EDIT_OTHERS ); + + try { + $edit_only = array( + user_can( self::$users['editor'], 'edit_dws_internal_comment', $internal ), + user_can( self::$users['editor'], 'trash_dws_internal_comment', $internal ), + user_can( self::$users['editor'], 'edit_comment', $internal ), + ); + $editor?->add_cap( DWS_IC_CAP_TRASH_OTHERS ); + $both = user_can( self::$users['editor'], 'edit_comment', $internal ); + } finally { + $editor?->remove_cap( DWS_IC_CAP_EDIT_OTHERS ); + $editor?->remove_cap( DWS_IC_CAP_TRASH_OTHERS ); + } + + self::assertSame( array( true, false, false ), $edit_only ); + self::assertTrue( $both ); + } + public function test_the_meta_capabilities_refuse_a_comment_that_is_not_internal(): void { $public = (int) wp_insert_comment( array( 'comment_post_ID' => $this->post( 'administrator' ) ) ); diff --git a/plugins/internal-comments/tests/Integration/RestRoutesTest.php b/plugins/internal-comments/tests/Integration/RestRoutesTest.php index bbc2124..a3629d9 100644 --- a/plugins/internal-comments/tests/Integration/RestRoutesTest.php +++ b/plugins/internal-comments/tests/Integration/RestRoutesTest.php @@ -141,6 +141,8 @@ public function test_a_comment_is_private_only_where_the_site_allows_it(): void } self::assertSame( 201, $allowed->get_status() ); self::assertSame( 403, $refused->get_status() ); + self::assertSame( 'dws_ic_cannot_be_private', $refused->get_data()['code'] ?? null ); + self::assertStringContainsString( 'was not posted', $refused->get_data()['message'] ?? '' ); self::assertSame( 201, $public->get_status() ); self::assertSame( array( 'Private' => true, 'Public' => false ), $private ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound -- Two expected values. } diff --git a/plugins/internal-comments/tests/e2e/admin-screens.spec.ts b/plugins/internal-comments/tests/e2e/admin-screens.spec.ts index ed41c37..9dd0002 100644 --- a/plugins/internal-comments/tests/e2e/admin-screens.spec.ts +++ b/plugins/internal-comments/tests/e2e/admin-screens.spec.ts @@ -69,4 +69,36 @@ test.describe( 'Posts list and Comments screen', () => { 'Seen in the list' ); } ); + + test( 'an internal comment’s edit screen shows only what saving changes', async ( { + admin, + page, + } ) => { + await admin.visitAdminPage( + 'edit-comments.php', + 'comment_type=dws_internal' + ); + const href = await page + .locator( '#the-comment-list' ) + .getByRole( 'link', { name: 'Edit this comment' } ) + .first() + .getAttribute( 'href' ); + const id = new URL( href ?? '', page.url() ).searchParams.get( 'c' ); + + await admin.visitAdminPage( + 'comment.php', + `action=editcomment&c=${ id ?? '' }` + ); + + await expect( page.locator( '#content' ) ).toHaveValue( + 'Seen in the list' + ); + for ( const selector of [ + '#namediv', + '#comment-status', + '.misc-pub-curtime', + ] ) { + await expect( page.locator( selector ) ).toBeHidden(); + } + } ); } );