diff --git a/src/wp-admin/css/dashboard.css b/src/wp-admin/css/dashboard.css
index 7b0d0fe01c493..fecfa17f5506e 100644
--- a/src/wp-admin/css/dashboard.css
+++ b/src/wp-admin/css/dashboard.css
@@ -937,12 +937,14 @@ body #dashboard-widgets .postbox form .submit {
}
#future-posts ul,
-#published-posts ul {
+#published-posts ul,
+#latest-notes ul {
margin: 8px -12px 0 -12px;
}
#future-posts li,
-#published-posts li {
+#published-posts li,
+#latest-notes li {
display: grid;
grid-template-columns: clamp(160px, calc(2vw + 140px), 200px) auto;
column-gap: 10px;
@@ -950,8 +952,14 @@ body #dashboard-widgets .postbox form .submit {
padding: 4px 12px;
}
+/* Recent Notes rows add a third column for the number of open notes. */
+#latest-notes li {
+ grid-template-columns: clamp(160px, calc(2vw + 140px), 200px) auto max-content;
+}
+
#future-posts li:nth-child(odd),
-#published-posts li:nth-child(odd) {
+#published-posts li:nth-child(odd),
+#latest-notes li:nth-child(odd) {
background-color: #f6f7f7;
}
diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php
index 488e01aa92481..a0b302e113152 100644
--- a/src/wp-admin/includes/dashboard.php
+++ b/src/wp-admin/includes/dashboard.php
@@ -935,6 +935,47 @@ function _wp_dashboard_recent_comments_row( &$comment, $show_date = true ) {
$GLOBALS['comment'] = null;
}
+/**
+ * Outputs a row for the Recent Notes widget. Notes are implemented as a type of comment.
+ *
+ * Each row represents a post with open notes, not an individual note.
+ *
+ * @access private
+ * @since 7.2.0
+ *
+ * @param WP_Comment $note The most recent open note of the post.
+ * @param int $open_notes Optional. Number of open notes the post has. Default 1.
+ */
+function _wp_dashboard_recent_notes_row( $note, $open_notes = 1 ) {
+ $note_post_id = (int) $note->comment_post_ID;
+
+ $time = strtotime( $note->comment_date_gmt . ' +0000' );
+
+ $note_post_title = _draft_or_post_title( $note_post_id );
+
+ // The post may have been deleted since the note was queried.
+ $note_post_link = get_edit_post_link( $note_post_id );
+
+ if ( ! $note_post_link ) {
+ return;
+ }
+
+ printf(
+ '
%1$s %4$s %5$s',
+ /* translators: %s: Human-readable time difference. */
+ sprintf( __( '%s ago' ), human_time_diff( $time ) ),
+ esc_url( $note_post_link ),
+ /* translators: %s: Post title. */
+ esc_attr( sprintf( __( 'Edit “%s”' ), $note_post_title ) ),
+ $note_post_title,
+ sprintf(
+ /* translators: %s: Number of open notes. */
+ _n( '%s open note', '%s open notes', $open_notes ),
+ number_format_i18n( $open_notes )
+ )
+ );
+}
+
/**
* Outputs the Activity widget.
*
@@ -967,7 +1008,9 @@ function wp_dashboard_site_activity() {
$recent_comments = wp_dashboard_recent_comments();
- if ( ! $future_posts && ! $recent_posts && ! $recent_comments ) {
+ $recent_notes = wp_dashboard_recent_notes();
+
+ if ( ! $future_posts && ! $recent_posts && ! $recent_comments && ! $recent_notes ) {
echo '';
echo '
' . __( 'No activity yet!' ) . '
';
echo '
';
@@ -1152,6 +1195,145 @@ function wp_dashboard_recent_comments( $total_items = 5 ) {
return true;
}
+/**
+ * Show Notes section.
+ *
+ * Lists the posts the current user can edit that have at least one open note,
+ * ordered by the note or reply most recently added to them.
+ *
+ * @since 7.2.0
+ *
+ * @param int $total_items Optional. Number of posts to display. Default 5.
+ * @return bool False if no posts with open notes were found. True otherwise.
+ */
+function wp_dashboard_recent_notes( $total_items = 5 ) {
+ /*
+ * Replies are queried alongside the notes that start a thread, so that a
+ * thread someone replied to counts as added to when it was replied to.
+ * Resolving a thread approves the note that starts it, while the replies
+ * stay on hold, so a reply is only recent activity while its thread is.
+ */
+ $notes_query = array(
+ 'type' => 'note',
+ 'status' => 'hold',
+ 'orderby' => 'comment_date_gmt',
+ 'order' => 'DESC',
+ 'number' => $total_items * 5,
+ 'offset' => 0,
+ // The posts are needed for the capability checks below, the note meta is not.
+ 'update_comment_post_cache' => true,
+ 'update_comment_meta_cache' => false,
+ );
+
+ // The most recent open note or reply of each post, keyed by post ID.
+ $latest_notes = array();
+
+ /*
+ * Only notes on posts the current user can edit are listed, and a user who
+ * can edit none of them would otherwise page through every open note on the
+ * site. Paging stops once a hundred notes per row have been looked at, so
+ * that the section costs a bounded number of queries however many notes the
+ * site has.
+ */
+ $max_notes_examined = $total_items * 100;
+ $notes_examined = 0;
+
+ do {
+ $possible = get_comments( $notes_query );
+
+ if ( empty( $possible ) || ! is_array( $possible ) ) {
+ break;
+ }
+
+ $notes_examined += count( $possible );
+
+ /*
+ * Prime the threads the replies belong to, so that they are not
+ * queried for one at a time below.
+ */
+ $thread_ids = array();
+
+ foreach ( $possible as $note ) {
+ if ( $note->comment_parent ) {
+ $thread_ids[] = (int) $note->comment_parent;
+ }
+ }
+
+ if ( $thread_ids ) {
+ _prime_comment_caches( array_unique( $thread_ids ), false );
+ }
+
+ foreach ( $possible as $note ) {
+ $note_post_id = (int) $note->comment_post_ID;
+
+ // Only the first note of a post is kept, as notes are ordered by date.
+ if ( isset( $latest_notes[ $note_post_id ] ) ) {
+ continue;
+ }
+
+ // A reply is resolved along with the thread it belongs to.
+ if ( $note->comment_parent ) {
+ $thread = get_comment( (int) $note->comment_parent );
+
+ if ( ! $thread || '0' !== $thread->comment_approved ) {
+ continue;
+ }
+ }
+
+ // Notes are only visible to users who can edit the post they belong to.
+ if ( ! current_user_can( 'edit_post', $note_post_id ) ) {
+ continue;
+ }
+
+ $latest_notes[ $note_post_id ] = $note;
+
+ if ( count( $latest_notes ) === $total_items ) {
+ break 2;
+ }
+ }
+
+ $notes_query['offset'] += $notes_query['number'];
+ $notes_query['number'] = min( $total_items * 10, $max_notes_examined - $notes_examined );
+ } while ( count( $latest_notes ) < $total_items && $notes_examined < $max_notes_examined );
+
+ if ( ! $latest_notes ) {
+ return false;
+ }
+
+ /*
+ * Count the open notes of each of the posts about to be displayed. A count
+ * query per post is cheaper than one query for every open note on them: the
+ * count is a single value, so the notes themselves are never loaded.
+ */
+ $open_notes = array();
+
+ foreach ( array_keys( $latest_notes ) as $note_post_id ) {
+ $open_notes[ $note_post_id ] = (int) get_comments(
+ array(
+ 'type' => 'note',
+ 'parent' => 0,
+ 'status' => 'hold',
+ 'post_id' => $note_post_id,
+ 'count' => true,
+ 'orderby' => 'none',
+ )
+ );
+ }
+
+ echo '';
+ echo '
' . __( 'Recent Notes' ) . '
';
+
+ echo '
';
+ foreach ( $latest_notes as $note_post_id => $note ) {
+ _wp_dashboard_recent_notes_row( $note, $open_notes[ $note_post_id ] );
+ }
+ echo '
';
+
+ echo '
';
+
+ return true;
+}
+
/**
* Display generic dashboard RSS widget feed.
*
diff --git a/tests/phpunit/tests/admin/includes/dashboard/WpDashboardRecentNotesRow_Test.php b/tests/phpunit/tests/admin/includes/dashboard/WpDashboardRecentNotesRow_Test.php
new file mode 100644
index 0000000000000..5e53c62bc0627
--- /dev/null
+++ b/tests/phpunit/tests/admin/includes/dashboard/WpDashboardRecentNotesRow_Test.php
@@ -0,0 +1,358 @@
+user->create( array( 'role' => 'administrator' ) );
+
+ self::$post_id = $factory->post->create(
+ array(
+ 'post_title' => 'A post with notes',
+ 'post_status' => 'draft',
+ 'post_author' => self::$admin_id,
+ )
+ );
+ }
+
+ public function set_up() {
+ parent::set_up();
+
+ require_once ABSPATH . 'wp-admin/includes/dashboard.php';
+
+ // Notes are only shown to users who can edit the post they belong to.
+ wp_set_current_user( self::$admin_id );
+ }
+
+ /**
+ * Creates an open note on the shared post.
+ *
+ * @param string $date Optional. Local date for the note, in MySQL format.
+ * Default is the current local time.
+ * @return WP_Comment The note.
+ */
+ private function create_note( $date = '' ) {
+ if ( '' === $date ) {
+ $date = current_time( 'mysql' );
+ }
+
+ $note_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_type' => 'note',
+ 'comment_content' => 'A note.',
+ // A note is created on hold, and is approved once it is resolved.
+ 'comment_approved' => '0',
+ 'comment_date' => $date,
+ 'comment_date_gmt' => get_gmt_from_date( $date ),
+ )
+ );
+
+ return get_comment( $note_id );
+ }
+
+ /**
+ * Renders a row and returns its markup.
+ *
+ * @param WP_Comment $note The note to render.
+ * @param int|null $open_notes Optional. Open note count. Default null, to
+ * call the function without the argument.
+ * @return string The rendered row.
+ */
+ private function render( $note, $open_notes = null ) {
+ ob_start();
+
+ if ( null === $open_notes ) {
+ _wp_dashboard_recent_notes_row( $note );
+ } else {
+ _wp_dashboard_recent_notes_row( $note, $open_notes );
+ }
+
+ return ob_get_clean();
+ }
+
+ /**
+ * Returns the relative time a rendered row starts with.
+ *
+ * @param string $output The rendered row.
+ * @return string The contents of the date element.
+ */
+ private function get_rendered_date( $output ) {
+ preg_match( '#([^<]*)#', $output, $matches );
+
+ $this->assertNotEmpty( $matches, 'The row did not render a date.' );
+
+ return $matches[1];
+ }
+
+ /**
+ * @ticket 65890
+ */
+ public function test_should_link_to_the_post_edit_screen() {
+ $output = $this->render( $this->create_note() );
+
+ $this->assertStringContainsString(
+ 'href="' . esc_url( get_edit_post_link( self::$post_id ) ) . '"',
+ $output,
+ 'The row did not link to the edit screen of the post.'
+ );
+ }
+
+ /**
+ * A post can be deleted between the moment its notes are queried and the
+ * moment the row is rendered.
+ *
+ * @ticket 65890
+ */
+ public function test_should_render_nothing_for_a_post_without_an_edit_link() {
+ $note = $this->create_note();
+
+ wp_delete_post( self::$post_id, true );
+
+ $this->assertSame(
+ '',
+ $this->render( $note ),
+ 'A note whose post has no edit link rendered a row.'
+ );
+ }
+
+ /**
+ * @ticket 65890
+ */
+ public function test_should_use_the_post_title_as_the_link_text() {
+ $output = $this->render( $this->create_note() );
+
+ $this->assertStringContainsString(
+ '>A post with notes',
+ $output,
+ 'The link text was not the post title.'
+ );
+ }
+
+ /**
+ * @ticket 65890
+ */
+ public function test_should_label_the_link_with_the_post_title() {
+ $output = $this->render( $this->create_note() );
+
+ $this->assertStringContainsString(
+ 'aria-label="Edit “A post with notes”"',
+ $output,
+ 'The link was not labelled with the post title.'
+ );
+ }
+
+ /**
+ * @ticket 65890
+ */
+ public function test_should_use_the_placeholder_title_for_an_untitled_post() {
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_title' => '',
+ 'post_status' => 'draft',
+ 'post_author' => self::$admin_id,
+ )
+ );
+
+ $note_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => $post_id,
+ 'comment_type' => 'note',
+ 'comment_approved' => '0',
+ )
+ );
+
+ $output = $this->render( get_comment( $note_id ) );
+
+ $this->assertStringContainsString(
+ '>(no title)',
+ $output,
+ 'An untitled post did not fall back to the placeholder title.'
+ );
+ }
+
+ /**
+ * @ticket 65890
+ */
+ public function test_should_escape_the_post_title() {
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_title' => '',
+ 'post_status' => 'draft',
+ 'post_author' => self::$admin_id,
+ )
+ );
+
+ $note_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => $post_id,
+ 'comment_type' => 'note',
+ 'comment_approved' => '0',
+ )
+ );
+
+ $output = $this->render( get_comment( $note_id ) );
+
+ $this->assertStringNotContainsString(
+ '