diff --git a/src/wp-admin/css/dashboard.css b/src/wp-admin/css/dashboard.css
index 17a9e312b85c6..b2195daa84218 100644
--- a/src/wp-admin/css/dashboard.css
+++ b/src/wp-admin/css/dashboard.css
@@ -934,12 +934,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;
@@ -947,8 +949,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;
}
@@ -1025,6 +1033,21 @@ body #dashboard-widgets .postbox form .submit {
top: 0;
}
+/* Dashboard activity widget - notes */
+
+#latest-notes ul {
+ margin: 8px -12px 0 -12px
+}
+
+#latest-notes li{
+ padding: 4px 12px;
+ color: #646970;
+}
+
+#latest-notes li:nth-child(odd) {
+ background-color: #f6f7f7;
+}
+
/* Browse happy box */
#dashboard-widgets #dashboard_browser_nag.postbox .inside {
diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php
index 7a78025214f10..2f6c21b743998 100644
--- a/src/wp-admin/includes/dashboard.php
+++ b/src/wp-admin/includes/dashboard.php
@@ -932,6 +932,59 @@ 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;
+
+ $today = current_time( 'Y-m-d' );
+ $tomorrow = current_datetime()->modify( '+1 day' )->format( 'Y-m-d' );
+ $year = current_time( 'Y' );
+
+ $time = get_comment_date( 'U', $note );
+
+ if ( ! is_int( $time ) ) {
+ /* translators: Date and time format for recent notes on the dashboard, from a different calendar year, see https://www.php.net/manual/datetime.format.php */
+ $date = get_comment_date( __( 'M jS Y' ), $note );
+ } elseif ( gmdate( 'Y-m-d', $time ) === $today ) {
+ $date = __( 'Today' );
+ } elseif ( gmdate( 'Y-m-d', $time ) === $tomorrow ) {
+ $date = __( 'Tomorrow' );
+ } elseif ( gmdate( 'Y', $time ) !== $year ) {
+ /* translators: Date and time format for recent notes on the dashboard, from a different calendar year, see https://www.php.net/manual/datetime.format.php */
+ $date = date_i18n( __( 'M jS Y' ), $time );
+ } else {
+ /* translators: Date and time format for recent notes on the dashboard, see https://www.php.net/manual/datetime.format.php */
+ $date = date_i18n( __( 'M jS' ), $time );
+ }
+
+ $note_post_title = _draft_or_post_title( $note_post_id );
+
+ printf(
+ '
%4$s %5$s %1$s',
+ /* translators: 1: Relative date, 2: Time. */
+ sprintf( _x( '%1$s, %2$s', 'dashboard' ), $date, get_comment_time( '', false, true, $note ) ),
+ esc_url( get_edit_post_link( $note_post_id ) ),
+ /* 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.
*
@@ -964,7 +1017,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 '
';
@@ -1149,6 +1204,130 @@ 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 most recent open note or reply of each post, keyed by post ID.
+ $latest_notes = array();
+
+ do {
+ $possible = get_comments( $notes_query );
+
+ if ( empty( $possible ) || ! is_array( $possible ) ) {
+ break;
+ }
+
+ /*
+ * 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'] = $total_items * 10;
+ } while ( count( $latest_notes ) < $total_items );
+
+ 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..a37bc01b771de
--- /dev/null
+++ b/tests/phpunit/tests/admin/includes/dashboard/WpDashboardRecentNotesRow_Test.php
@@ -0,0 +1,371 @@
+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 date and time a rendered row ends 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];
+ }
+
+ /**
+ * Returns the relative date a rendered row ends with, without the time.
+ *
+ * @param string $output The rendered row.
+ * @return string The date, without the trailing time.
+ */
+ private function get_rendered_day( $output ) {
+ $date = explode( ', ', $this->get_rendered_date( $output ), 2 );
+
+ return $date[0];
+ }
+
+ /**
+ * @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.'
+ );
+ }
+
+ /**
+ * @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(
+ '