diff --git a/src/wp-admin/css/dashboard.css b/src/wp-admin/css/dashboard.css index ab73f828f7067..4a84c4c637e90 100644 --- a/src/wp-admin/css/dashboard.css +++ b/src/wp-admin/css/dashboard.css @@ -1023,6 +1023,49 @@ body #dashboard-widgets .postbox form .submit { top: 0; } +/* On This Day dashboard widget */ + +#wp_dashboard_on_this_day .wp-on-this-day-title { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.wp-on-this-day-date { + padding: 2px 9px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.3px; + text-transform: uppercase; + color: var(--wp-admin-theme-color-darker-10, #135e96); + background: rgba(var(--wp-admin-theme-color--rgb, 34, 113, 177), 0.08); + border-radius: 9999px; + white-space: nowrap; +} + +#wp_dashboard_on_this_day h3 { + font-weight: 600; +} + +#wp_dashboard_on_this_day li { + margin: 0; + padding: 0; +} + +#wp_dashboard_on_this_day ul ul { + margin: 0 0 0 18px; + padding: 0; + list-style: disc; +} + +#wp_dashboard_on_this_day .wp-on-this-day-year + .wp-on-this-day-year { + margin-top: 16px; +} + +#wp_dashboard_on_this_day .wp-on-this-day-post-author { + color: #646970; +} + /* Browse happy box */ #dashboard-widgets #dashboard_browser_nag.postbox .inside { diff --git a/src/wp-admin/includes/dashboard-on-this-day.php b/src/wp-admin/includes/dashboard-on-this-day.php new file mode 100644 index 0000000000000..be85b46f0ab28 --- /dev/null +++ b/src/wp-admin/includes/dashboard-on-this-day.php @@ -0,0 +1,227 @@ +%s %s', + esc_html__( 'On This Day' ), + /* translators: Date format for the On This Day widget date, without year. See https://www.php.net/manual/datetime.format.php */ + esc_html( wp_date( _x( 'F jS', 'on this day date format' ) ) ) + ), + 'wp_dashboard_on_this_day' + ); +} + +/** + * Renders the On This Day dashboard widget. + * + * Outputs the matching posts grouped by publication year, newest year first. + * + * @since 7.1.0 + * + * @global WP_Post $post Global post object. + */ +function wp_dashboard_on_this_day() { + global $post; + + $posts = wp_dashboard_on_this_day_get_posts(); + + if ( empty( $posts ) ) { + return; + } + + $posts_by_year = array(); + $post_count = count( $posts ); + + foreach ( $posts as $current_post ) { + $year = get_the_date( 'Y', $current_post ); + + if ( ! isset( $posts_by_year[ $year ] ) ) { + $posts_by_year[ $year ] = array(); + } + + $posts_by_year[ $year ][] = $current_post; + } + ?> +
+

+ +

+ +
+ format( 'Y' ); + $date_query = array( + 'relation' => 'AND', + array( + 'before' => array( 'year' => $year ), + ), + _wp_dashboard_on_this_day_date_query_clause( $today ), + ); + + $args = array( + 'post_type' => 'post', + 'post_status' => array( 'publish' ), + 'posts_per_page' => 10, + 'ignore_sticky_posts' => true, + 'orderby' => 'date', + 'order' => 'DESC', + 'no_found_rows' => true, + 'update_post_term_cache' => false, + 'date_query' => $date_query, + ); + + /** + * Filters the arguments used to query posts for the On This Day dashboard widget. + * + * @since 7.1.0 + * + * @param array $args WP_Query arguments. + */ + $args = apply_filters( 'wp_dashboard_on_this_day_query_args', $args ); + + $query = new WP_Query( $args ); + + return $query->posts; +} + +/** + * Builds the date query clause for today's anniversary date. + * + * On February 28 in a non-leap year, February 29 posts are included so + * leap-day anniversaries still appear. + * + * @since 7.1.0 + * @access private + * + * @param DateTimeInterface $date Date to build the clause for. + * @return array Date query clause. + */ +function _wp_dashboard_on_this_day_date_query_clause( $date ) { + $month = (int) $date->format( 'm' ); + $day = (int) $date->format( 'd' ); + $clause = array( + 'month' => $month, + 'day' => $day, + ); + + // Display leap day posts on Feb 28 in non leap years. + if ( + 28 === $day + && 2 === $month + && false === (bool) $date->format( 'L' ) + ) { + $clause = array( + 'relation' => 'OR', + $clause, + array( + 'month' => 2, + 'day' => 29, + ), + ); + } + + return $clause; +} + +/** + * Renders a single linked post title for the On This Day dashboard widget. + * + * Must be called with the global post set up via `setup_postdata()`. + * + * @since 7.1.0 + * @access private + */ +function _wp_dashboard_on_this_day_post() { + $title = get_the_title(); + + if ( '' === trim( $title ) ) { + $title = __( '(no title)' ); + } + + $author_id = (int) get_post_field( 'post_author', get_the_ID() ); + $author_name = $author_id > 0 ? (string) get_the_author() : ''; + $show_author = '' !== trim( $author_name ) && get_current_user_id() !== $author_id; + ?> +
  • + + + ' . esc_html( + sprintf( + /* translators: %s: Post author's display name. */ + __( 'by %s' ), + $author_name + ) + ) . ''; + ?> + +
  • + modify( '-' . $years_ago . ' years' )->format( 'Y-m-d' ) . ' ' . $time; + + return self::factory()->post->create( + array( + 'post_author' => $author_id, + 'post_date' => $post_date, + 'post_date_gmt' => get_gmt_from_date( $post_date ), + 'post_status' => 'publish', + 'post_title' => $title, + ) + ); + } + + /** + * Creates a published post near, but not on, today's prior-year calendar day. + * + * @param int $author_id Author ID. + * @param string $title Post title. + * @param int $day_offset Number of days from today's prior-year calendar day. + * @return int Post ID. + */ + private function create_nearby_post( $author_id, $title = 'Almost a memory', $day_offset = 1 ) { + $post_date = current_datetime() + ->modify( '-1 year' ) + ->modify( ( $day_offset >= 0 ? '+' : '' ) . $day_offset . ' days' ) + ->format( 'Y-m-d' ) . ' 12:00:00'; + + return self::factory()->post->create( + array( + 'post_author' => $author_id, + 'post_date' => $post_date, + 'post_date_gmt' => get_gmt_from_date( $post_date ), + 'post_status' => 'publish', + 'post_title' => $title, + ) + ); + } + + /** + * Invokes _wp_dashboard_on_this_day_date_query_clause(). + * + * @param string $date Date string. + * @return array Date query clause. + */ + private static function get_date_query_clause( $date ) { + return _wp_dashboard_on_this_day_date_query_clause( new DateTimeImmutable( $date, wp_timezone() ) ); + } + + /** + * @covers ::wp_dashboard_on_this_day_setup + */ + public function test_setup_does_not_add_dashboard_widget_without_matching_posts() { + $this->set_up_dashboard_screen(); + + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + wp_set_current_user( $user_id ); + + wp_dashboard_on_this_day_setup(); + + $dashboard_widgets = $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core'] ?? array(); + + $this->assertArrayNotHasKey( 'wp_dashboard_on_this_day', $dashboard_widgets ); + } + + /** + * @covers ::wp_dashboard_on_this_day_setup + */ + public function test_setup_adds_dashboard_widget_with_matching_posts() { + $this->set_up_dashboard_screen(); + + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + wp_set_current_user( $user_id ); + $this->create_matching_post( $user_id ); + + wp_dashboard_on_this_day_setup(); + + $dashboard_widgets = $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core'] ?? array(); + + $this->assertArrayHasKey( 'wp_dashboard_on_this_day', $dashboard_widgets ); + $this->assertStringContainsString( + '' . esc_html( wp_date( 'F jS' ) ) . '', + $dashboard_widgets['wp_dashboard_on_this_day']['title'] + ); + } + + /** + * @covers ::wp_dashboard_on_this_day_setup + */ + public function test_setup_adds_dashboard_widget_with_matching_post_from_another_author() { + $this->set_up_dashboard_screen(); + + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + $other_user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + wp_set_current_user( $user_id ); + $this->create_matching_post( $other_user_id ); + + wp_dashboard_on_this_day_setup(); + + $dashboard_widgets = $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core'] ?? array(); + + $this->assertArrayHasKey( 'wp_dashboard_on_this_day', $dashboard_widgets ); + } + + /** + * @covers ::_wp_dashboard_on_this_day_date_query_clause + */ + public function test_get_date_query_clause_includes_february_29_on_february_28_in_non_leap_year() { + $clause = self::get_date_query_clause( '2023-02-28 12:00:00' ); + + $this->assertSame( + array( + 'relation' => 'OR', + array( + 'month' => 2, + 'day' => 28, + ), + array( + 'month' => 2, + 'day' => 29, + ), + ), + $clause + ); + } + + /** + * @covers ::_wp_dashboard_on_this_day_date_query_clause + */ + public function test_get_date_query_clause_does_not_include_february_29_on_february_28_in_leap_year() { + $clause = self::get_date_query_clause( '2024-02-28 12:00:00' ); + + $this->assertSame( + array( + 'month' => 2, + 'day' => 28, + ), + $clause + ); + } + + /** + * @covers ::_wp_dashboard_on_this_day_date_query_clause + */ + public function test_get_date_query_clause_matches_february_29_on_leap_day() { + $clause = self::get_date_query_clause( '2024-02-29 12:00:00' ); + + $this->assertSame( + array( + 'month' => 2, + 'day' => 29, + ), + $clause + ); + } + + /** + * @covers ::wp_dashboard_on_this_day + */ + public function test_widget_outputs_nothing_without_matching_posts() { + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + wp_set_current_user( $user_id ); + + ob_start(); + wp_dashboard_on_this_day(); + $output = ob_get_clean(); + + $this->assertSame( '', $output ); + } + + /** + * @covers ::wp_dashboard_on_this_day + */ + public function test_widget_ignores_nearby_prior_year_posts() { + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + wp_set_current_user( $user_id ); + $this->create_nearby_post( $user_id ); + + ob_start(); + wp_dashboard_on_this_day(); + $output = ob_get_clean(); + + $this->assertSame( '', $output ); + } + + /** + * @covers ::wp_dashboard_on_this_day + */ + public function test_widget_labels_posts_from_other_authors() { + $user_id = self::factory()->user->create( + array( + 'display_name' => 'Current Writer', + 'role' => 'author', + ) + ); + $other_user_id = self::factory()->user->create( + array( + 'display_name' => 'Guest Writer', + 'role' => 'author', + ) + ); + wp_set_current_user( $user_id ); + + $this->create_matching_post( $user_id, 'A note from me' ); + $this->create_matching_post( $other_user_id, 'A note from someone else' ); + + ob_start(); + wp_dashboard_on_this_day(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'A note from me', $output ); + $this->assertStringNotContainsString( 'by Current Writer', $output ); + $this->assertStringContainsString( 'A note from someone else', $output ); + $this->assertStringContainsString( 'by Guest Writer', $output ); + $this->assertStringContainsString( 'by Guest Writer', $output ); + } + + /** + * @covers ::wp_dashboard_on_this_day + */ + public function test_widget_groups_posts_by_year() { + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + wp_set_current_user( $user_id ); + + $this->create_matching_post( $user_id, 'Pretending to meditate', 1, '12:00:00' ); + $this->create_matching_post( $user_id, 'Slow internet and good books', 1, '11:00:00' ); + $this->create_matching_post( $user_id, 'Late-night shipping log', 2, '12:00:00' ); + + ob_start(); + wp_dashboard_on_this_day(); + $output = ob_get_clean(); + + $last_year = current_datetime()->modify( '-1 year' )->format( 'Y' ); + $two_years_ago = current_datetime()->modify( '-2 years' )->format( 'Y' ); + + $this->assertStringContainsString( '3 posts have been published on this day:', $output ); + $this->assertStringContainsString( '