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 ''; + + 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( + '