Make WordPress Core

Ticket #40702: 40702-ajax.diff

File 40702-ajax.diff, 56.4 KB (added by iandunn, 7 years ago)

Switch from REST API to admin AJAX until long-term API decisions are made

  • src/wp-admin/admin-ajax.php

    diff --git src/wp-admin/admin-ajax.php src/wp-admin/admin-ajax.php
    index e0f4464d94..3213d55028 100644
    $core_actions_post = array( 
    6464        'parse-media-shortcode', 'destroy-sessions', 'install-plugin', 'update-plugin', 'press-this-save-post',
    6565        'press-this-add-category', 'crop-image', 'generate-password', 'save-wporg-username', 'delete-plugin',
    6666        'search-plugins', 'search-install-plugins', 'activate-plugin', 'update-theme', 'delete-theme',
    67         'install-theme', 'get-post-thumbnail-html',
     67        'install-theme', 'get-post-thumbnail-html', 'get-community-events',
    6868);
    6969
    7070// Deprecated
  • src/wp-admin/css/dashboard.css

    diff --git src/wp-admin/css/dashboard.css src/wp-admin/css/dashboard.css
    index 98fb99378d..6a35594302 100644
     
    301301        content: "\f153";
    302302}
    303303
     304/* Dashboard WordPress events */
     305
     306.community-events-errors {
     307        margin: 0;
     308}
     309
     310.community-events-loading {
     311        padding: 10px 12px 8px;
     312}
     313
     314.community-events {
     315        margin-bottom: 6px;
     316        padding: 0 12px;
     317}
     318
     319.community-events .spinner {
     320        float: none;
     321        margin: 0;
     322        padding-bottom: 3px;
     323}
     324
     325.community-events-errors[aria-hidden="true"],
     326.community-events-errors *[aria-hidden="true"],
     327.community-events-loading[aria-hidden="true"],
     328.community-events[aria-hidden="true"],
     329.community-events *[aria-hidden="true"] {
     330        display: none;
     331}
     332
     333.community-events .activity-block:first-child,
     334.community-events h2 {
     335        padding-top: 12px;
     336        padding-bottom: 10px;
     337}
     338
     339.community-events-form {
     340        margin: 15px 0 5px;
     341}
     342
     343.community-events-form .regular-text {
     344        width: 40%;
     345        height: 28px;
     346}
     347
     348.community-events li.event-none {
     349        border-left: 4px solid #0070AE;
     350}
     351
     352.community-events-form label {
     353        display: inline-block;
     354        padding-bottom: 3px;
     355}
     356
     357.community-events .activity-block > p {
     358        margin-bottom: 0;
     359        display: inline;
     360}
     361
     362#community-events-submit {
     363        margin-left: 2px;
     364}
     365
     366.community-events .button-link:hover,
     367.community-events .button-link:active {
     368        color: #00a0d2;
     369}
     370
     371.community-events-cancel.button.button-link {
     372        color: #0073aa;
     373        text-decoration: underline;
     374        margin-left: 2px;
     375}
     376
     377.community-events ul {
     378        background-color: #fafafa;
     379        padding-left: 0;
     380        padding-right: 0;
     381        padding-bottom: 0;
     382}
     383
     384.community-events li {
     385        margin: 0;
     386        padding: 8px 12px;
     387        color: #72777c;
     388}
     389.community-events li:first-child {
     390        border-top: 1px solid #eee;
     391}
     392
     393.community-events li ~ li {
     394        border-top: 1px solid #eee;
     395}
     396
     397.community-events .activity-block.last {
     398        border-bottom: 1px solid #eee;
     399        padding-top: 0;
     400        margin-top: -1px;
     401}
     402
     403.community-events .event-info {
     404        display: block;
     405}
     406
     407.event-icon {
     408        height: 18px;
     409        padding-right: 10px;
     410        width: 18px;
     411        display: none; /* Hide on smaller screens */
     412}
     413
     414.event-icon:before {
     415        color: #82878C;
     416        font-size: 18px;
     417}
     418.event-meetup .event-icon:before {
     419        content: "\f484";
     420}
     421.event-wordcamp .event-icon:before {
     422        content: "\f486";
     423}
     424
     425.community-events .event-title {
     426        font-weight: 600;
     427        display: block;
     428}
     429
     430.community-events .event-date,
     431.community-events .event-time {
     432        display: block;
     433}
     434
     435.community-events-footer {
     436        margin-top: 0;
     437        margin-bottom: 0;
     438        padding: 12px;
     439        border-top: 1px solid #eee;
     440        color: #ddd;
     441}
     442
    304443/* Dashboard WordPress news */
    305444
    306445#dashboard_primary .inside {
    body #dashboard-widgets .postbox form .submit { 
    333472}
    334473
    335474#dashboard_primary .rss-widget {
    336         border-bottom: 1px solid #eee;
    337475        font-size: 13px;
    338         padding: 8px 12px 10px;
     476        padding: 0 12px 0;
    339477}
    340478
    341479#dashboard_primary .rss-widget:last-child {
    body #dashboard-widgets .postbox form .submit { 
    357495}
    358496
    359497#dashboard_primary .rss-widget ul li {
    360         margin-bottom: 8px;
     498        padding: 4px 0;
     499        margin: 0;
    361500}
    362501
    363502/* Dashboard right now */
    form.initial-form.quickpress-open input#title { 
    8741013}
    8751014
    8761015a.rsswidget {
    877         font-size: 14px;
     1016        font-size: 13px;
    8781017        font-weight: 600;
    879         line-height: 1.7em;
     1018        line-height: 1.4em;
    8801019}
    8811020
    8821021.rss-widget ul li {
    a.rsswidget { 
    10871226                width: 30px;
    10881227                margin: 4px 10px 5px 0;
    10891228        }
     1229
     1230        .community-events-toggle-location {
     1231                height: 38px;
     1232        }
     1233
     1234        .community-events-form .regular-text {
     1235                height: 31px;
     1236        }
    10901237}
    10911238
    10921239/* Smartphone */
    a.rsswidget { 
    11101257                left: -35px;
    11111258        }
    11121259}
     1260
     1261@media screen and (min-width: 355px) {
     1262        .community-events .event-info {
     1263                display: table-row;
     1264                float: left;
     1265                max-width: 59%;
     1266        }
     1267
     1268        .event-icon,
     1269        .event-icon[aria-hidden="true"] {
     1270                display: table-cell;
     1271        }
     1272
     1273        .event-info-inner {
     1274                display: table-cell;
     1275        }
     1276
     1277        .community-events .event-date-time {
     1278                float: right;
     1279                max-width: 39%;
     1280        }
     1281
     1282        .community-events .event-date,
     1283        .community-events .event-time {
     1284                text-align: right;
     1285        }
     1286}
  • src/wp-admin/includes/ajax-actions.php

    diff --git src/wp-admin/includes/ajax-actions.php src/wp-admin/includes/ajax-actions.php
    index 3342cad375..a2e829bf65 100644
    function wp_ajax_autocomplete_user() { 
    297297}
    298298
    299299/**
     300 * Handles AJAX requests for community events
     301 *
     302 * @since 4.8.0
     303 */
     304function wp_ajax_get_community_events() {
     305        require_once( ABSPATH . 'wp-admin/includes/class-wp-community-events.php' );
     306
     307        check_ajax_referer( 'community_events' );
     308
     309        $search         = isset( $_POST['location'] ) ? wp_unslash( $_POST['location'] ) : '';
     310        $timezone       = isset( $_POST['timezone'] ) ? wp_unslash( $_POST['timezone'] ) : '';
     311        $user_id        = get_current_user_id();
     312        $saved_location = get_user_option( 'community-events-location', $user_id );
     313        $events_client  = new WP_Community_Events( $user_id, $saved_location );
     314        $events         = $events_client->get_events( $search, $timezone );
     315
     316        if ( is_wp_error( $events ) ) {
     317                wp_send_json_error( array(
     318                        'error' => $events->get_error_message(),
     319                ) );
     320        } else {
     321                if ( isset( $events['location'] ) ) {
     322                        // Store the location network-wide, so the user doesn't have to set it on each site.
     323                        update_user_option( $user_id, 'community-events-location', $events['location'], true );
     324                }
     325
     326                wp_send_json_success( $events );
     327        }
     328}
     329
     330/**
    300331 * Ajax handler for dashboard widgets.
    301332 *
    302333 * @since 3.4.0
  • new file src/wp-admin/includes/class-wp-community-events.php

    diff --git src/wp-admin/includes/class-wp-community-events.php src/wp-admin/includes/class-wp-community-events.php
    new file mode 100644
    index 0000000000..6d85524457
    - +  
     1<?php
     2/**
     3 * Administration: Community Events class
     4 *
     5 * @package WordPress
     6 * @subpackage Administration
     7 * @since 4.8.0
     8 */
     9
     10/**
     11 * Class WP_Community_Events
     12 *
     13 * A client for api.wordpress.org/events.
     14 *
     15 * @since 4.8.0
     16 */
     17class WP_Community_Events {
     18        /**
     19         * ID for a WordPress user account.
     20         *
     21         * @access protected
     22         * @since 4.8.0
     23         *
     24         * @var int
     25         */
     26        protected $user_id = 0;
     27
     28        /**
     29         * Stores location data for the user.
     30         *
     31         * @access protected
     32         * @since 4.8.0
     33         *
     34         * @var bool|array
     35         */
     36        protected $user_location = false;
     37
     38        /**
     39         * Constructor for WP_Community_Events.
     40         *
     41         * @access public
     42         * @since 4.8.0
     43         *
     44         * @param int        $user_id       WP user ID.
     45         * @param bool|array $user_location Stored location data for the user.
     46         *                                  `false` to pass no location;
     47         *                                  `array` to pass a location {
     48         *     @type string $description The name of the location
     49         *     @type string $latitude    The latitude in decimal degrees notation, without the degree
     50         *                               symbol. e.g., `47.615200`.
     51         *     @type string $longitude   The longitude in decimal degrees notation, without the degree
     52         *                               symbol. e.g., `-122.341100`.
     53         *     @type string $country     The ISO 3166-1 alpha-2 country code. e.g., `BR`
     54         * }
     55         */
     56        public function __construct( $user_id, $user_location = false ) {
     57                $this->user_id       = absint( $user_id );
     58                $this->user_location = $user_location;
     59        }
     60
     61        /**
     62         * Gets data about events near a particular location.
     63         *
     64         * Cached events will be immediately returned if the `user_location` property
     65         * is set for the current user, and cached events exist for that location.
     66         *
     67         * Otherwise, this method sends a request to the w.org Events API with location
     68         * data. The API will send back a recognized location based on the data, along
     69         * with nearby events.
     70         *
     71         * @access public
     72         * @since 4.8.0
     73         *
     74         * @param string $location_search Optional city name to help determine the location.
     75         *                                e.g., "Seattle". Default empty string.
     76         * @param string $timezone        Optional timezone to help determine the location.
     77         *                                Default empty string.
     78         * @return array|WP_Error         A WP_Error on failure; an array with location and events on
     79         *                                success.
     80         */
     81        public function get_events( $location_search = '', $timezone = '' ) {
     82                $cached_events = $this->get_cached_events();
     83
     84                if ( ! $location_search && $cached_events ) {
     85                        return $cached_events;
     86                }
     87
     88                $request_url    = $this->get_request_url( $location_search, $timezone );
     89                $response       = wp_remote_get( $request_url );
     90                $response_code  = wp_remote_retrieve_response_code( $response );
     91                $response_body  = json_decode( wp_remote_retrieve_body( $response ), true );
     92                $response_error = null;
     93                $debugging_info = compact( 'request_url', 'response_code', 'response_body' );
     94
     95                if ( is_wp_error( $response ) ) {
     96                        $response_error = $response;
     97                } elseif ( 200 !== $response_code ) {
     98                        $response_error = new WP_Error(
     99                                'api-error',
     100                                /* translators: %s is a numeric HTTP status code; e.g., 400, 403, 500, 504, etc. */
     101                                sprintf( __( 'Invalid API response code (%d)' ), $response_code )
     102                        );
     103                } elseif ( ! isset( $response_body['location'], $response_body['events'] ) ) {
     104                        $response_error = new WP_Error(
     105                                'api-invalid-response',
     106                                isset( $response_body['error'] ) ? $response_body['error'] : __( 'Unknown API error.' )
     107                        );
     108                }
     109
     110                if ( is_wp_error( $response_error ) ) {
     111                        $this->maybe_log_events_response( $response_error->get_error_message(), $debugging_info );
     112
     113                        return $response_error;
     114                } else {
     115                        $expiration = false;
     116
     117                        if ( isset( $response_body['ttl'] ) ) {
     118                                $expiration = $response_body['ttl'];
     119                                unset( $response_body['ttl'] );
     120                        }
     121
     122                        $this->cache_events( $response_body, $expiration );
     123
     124                        $response_body = $this->trim_events( $response_body );
     125                        $response_body = $this->format_event_data_time( $response_body );
     126
     127                        // Avoid bloating the log with all the event data, but keep the count.
     128                        $debugging_info['response_body']['events'] = count( $debugging_info['response_body']['events'] ) . ' events trimmed.';
     129
     130                        $this->maybe_log_events_response( 'Valid response received', $debugging_info );
     131
     132                        return $response_body;
     133                }
     134        }
     135
     136        /**
     137         * Builds a URL for requests to the w.org Events API
     138         *
     139         * @access protected
     140         * @since 4.8.0
     141         *
     142         * @param  string $search   City search string. Default empty string.
     143         * @param  string $timezone Timezone string. Default empty string.
     144         * @return string           The request URL.
     145         */
     146        protected function get_request_url( $search = '', $timezone = '' ) {
     147                $api_url = 'https://api.wordpress.org/events/1.0/';
     148                $args    = array( 'number' => 5 ); // Get more than three in case some get trimmed out.
     149
     150                /*
     151                 * Send the minimal set of necessary arguments, in order to increase the
     152                 * chances of a cache-hit on the API side.
     153                 */
     154                if ( empty( $search ) && isset( $this->user_location['latitude'], $this->user_location['longitude'] ) ) {
     155                        $args['latitude']  = $this->user_location['latitude'];
     156                        $args['longitude'] = $this->user_location['longitude'];
     157                } else {
     158                        $args['locale'] = get_user_locale( $this->user_id );
     159
     160                        if ( $timezone ) {
     161                                $args['timezone'] = $timezone;
     162                        }
     163
     164                        if ( $search ) {
     165                                $args['location'] = $search;
     166                        } else {
     167                                /*
     168                                 * Protect the user's privacy by anonymizing their IP before sending
     169                                 * it to w.org, and only send it when necessary.
     170                                 *
     171                                 * The w.org API endpoint only uses the IP address when a location
     172                                 * query is not provided, so we can safely avoid sending it when
     173                                 * there is a query.
     174                                 */
     175                                $args['ip'] = $this->maybe_anonymize_ip_address( $this->get_unsafe_client_ip() );
     176                        }
     177                }
     178
     179                return add_query_arg( $args, $api_url );
     180        }
     181
     182        /**
     183         * Determines the user's actual IP address, if possible
     184         *
     185         * $_SERVER['REMOTE_ADDR'] cannot be used in all cases, such as when the user
     186         * is making their request through a proxy, or when the web server is behind
     187         * a proxy. In those cases, `REMOTE_ADDR` is set to the proxy address rather
     188         * than the user's actual address.
     189         *
     190         * Modified from http://stackoverflow.com/a/2031935/450127, MIT license.
     191         *
     192         * SECURITY WARNING: This function is _NOT_ intended to be used in
     193         * circumstances where the authenticity of the IP address matters. This does
     194         * _NOT_ guarantee that the returned address is valid or accurate, and it can
     195         * be easily spoofed.
     196         *
     197         * @access protected
     198         * @since 4.8.0
     199         *
     200         * @return false|string `false` on failure, the `string` address on success
     201         */
     202        protected function get_unsafe_client_ip() {
     203                $client_ip = false;
     204
     205                // In order of preference, with the best ones for this purpose first.
     206                $address_headers = array(
     207                        'HTTP_CLIENT_IP',
     208                        'HTTP_X_FORWARDED_FOR',
     209                        'HTTP_X_FORWARDED',
     210                        'HTTP_X_CLUSTER_CLIENT_IP',
     211                        'HTTP_FORWARDED_FOR',
     212                        'HTTP_FORWARDED',
     213                        'REMOTE_ADDR',
     214                );
     215
     216                foreach ( $address_headers as $header ) {
     217                        if ( array_key_exists( $header, $_SERVER ) ) {
     218                                /*
     219                                 * HTTP_X_FORWARDED_FOR can contain a chain of comma-separated
     220                                 * addresses. The first one is the original client. It can't be
     221                                 * trusted for authenticity, but we don't need to for this purpose.
     222                                 */
     223                                $address_chain = explode( ',', $_SERVER[ $header ] );
     224                                $client_ip     = trim( $address_chain[0] );
     225
     226                                break;
     227                        }
     228                }
     229
     230                return $client_ip;
     231        }
     232
     233        /**
     234         * Attempts to partially anonymize an IP address by converting it a network ID
     235         *
     236         * Geolocating the network ID usually returns a similar location as the
     237         * actual IP, but provides some privacy for the user.
     238         *
     239         * Modified from https://github.com/geertw/php-ip-anonymizer, MIT license.
     240         *
     241         * @access protected
     242         * @since 4.8.0
     243         *
     244         * @param  string      $address The IP address that should be anonymized.
     245         * @return bool|string          The anonymized address on success;
     246         *                              the given address or false on failure.
     247         */
     248        protected function maybe_anonymize_ip_address( $address ) {
     249                // These functions are not available on Windows until PHP 5.3.
     250                if ( ! function_exists( 'inet_pton' ) || ! function_exists( 'inet_ntop' ) ) {
     251                        return $address;
     252                }
     253
     254                if ( 4 === strlen( inet_pton( $address ) ) ) {
     255                        $netmask = '255.255.255.0'; // ipv4.
     256                } else {
     257                        $netmask = 'ffff:ffff:ffff:ffff:0000:0000:0000:0000'; // ipv6.
     258                }
     259
     260                return inet_ntop( inet_pton( $address ) & inet_pton( $netmask ) );
     261        }
     262
     263        /**
     264         * Generates a transient key based on user location
     265         *
     266         * This could be reduced to a one-liner in the calling functions, but it's
     267         * intentionally a separate function because it's called from multiple
     268         * functions, and having it abstracted keeps the logic consistent and DRY,
     269         * which is less prone to errors.
     270         *
     271         * @access protected
     272         * @since 4.8.0
     273         *
     274         * @param  array       $location Should contain 'latitude' and 'longitude' indexes.
     275         * @return bool|string           `false` on failure, or a string on success
     276         */
     277        protected function get_events_transient_key( $location ) {
     278                $key = false;
     279
     280                if ( isset( $location['latitude'], $location['longitude'] ) ) {
     281                        $key = 'community-events-' . md5( $location['latitude'] . $location['longitude'] );
     282                }
     283
     284                return $key;
     285        }
     286
     287        /**
     288         * Caches an array of events data from the Events API.
     289         *
     290         * @access protected
     291         * @since 4.8.0
     292         *
     293         * @param array    $events               Response body from the API request.
     294         * @param int|bool $expiration Optional. Amount of time to cache the events. Defaults to false.
     295         * @return bool `true` if events were cached; `false` if not.
     296         */
     297        protected function cache_events( $events, $expiration = false ) {
     298                $set              = false;
     299                $transient_key    = $this->get_events_transient_key( $events['location'] );
     300                $cache_expiration = $expiration ? absint( $expiration ) : HOUR_IN_SECONDS * 12;
     301
     302                if ( $transient_key ) {
     303                        $set = set_site_transient( $transient_key, $events, $cache_expiration );
     304                }
     305
     306                return $set;
     307        }
     308
     309        /**
     310         * Gets cached events
     311         *
     312         * @access public
     313         * @since 4.8.0
     314         *
     315         * @return false|array `false` on failure; an array containing `location`
     316         *                     and `events` items on success.
     317         */
     318        public function get_cached_events() {
     319                $cached_response = get_site_transient( $this->get_events_transient_key( $this->user_location ) );
     320                $cached_response = $this->trim_events( $cached_response );
     321
     322                return $this->format_event_data_time( $cached_response );
     323        }
     324
     325        /**
     326         * Adds formatted date and time items for each event in an API response
     327         *
     328         * This has to be called after the data is pulled from the cache, because
     329         * the cached events are shared by all users. If it was called before storing
     330         * the cache, then all users would see the events in the localized data/time
     331         * of the user who triggered the cache refresh, rather than their own.
     332         *
     333         * @access protected
     334         * @since 4.8.0
     335         *
     336         * @param  array $response_body The response which contains the events.
     337         * @return array                The response with dates and times formatted
     338         */
     339        protected function format_event_data_time( $response_body ) {
     340                if ( isset( $response_body['events'] ) ) {
     341                        foreach ( $response_body['events'] as $key => $event ) {
     342                                $timestamp = strtotime( $event['date'] );
     343
     344                                /*
     345                                 * The `date_format` option is not used because it's important
     346                                 * in this context to keep the day of the week in the formatted date,
     347                                 * so that users can tell at a glance if the event is on a day they
     348                                 * are available, without having to open the link.
     349                                 */
     350                                /* translators: Date format for upcoming events on the dashboard. Include the day of the week. See https://secure.php.net/date. */
     351                                $response_body['events'][ $key ]['formatted_date'] = date_i18n( __( 'l, M j, Y' ), $timestamp );
     352                                $response_body['events'][ $key ]['formatted_time'] = date_i18n( get_option( 'time_format' ), $timestamp );
     353                        }
     354                }
     355
     356                return $response_body;
     357        }
     358
     359        /**
     360         * Discards expired events, and reduces the remaining list
     361         *
     362         * @access protected
     363         * @since 4.8.0
     364         *
     365         * @param  array $response_body The response body which contains the events.
     366         * @return array                The response body with events trimmed.
     367         */
     368        protected function trim_events( $response_body ) {
     369                if ( isset( $response_body['events'] ) ) {
     370                        $current_timestamp = current_time('timestamp' );
     371
     372                        foreach ( $response_body['events'] as $key => $event ) {
     373                                // Skip WordCamps, because they might be multi-day events.
     374                                if ( 'meetup' !== $event['type'] ) {
     375                                        continue;
     376                                }
     377
     378                                $event_timestamp = strtotime( $event['date'] );
     379
     380                                if ( $current_timestamp > $event_timestamp && ( $current_timestamp - $event_timestamp ) > DAY_IN_SECONDS ) {
     381                                        unset( $response_body['events'][ $key ] );
     382                                }
     383                        }
     384
     385                        $response_body['events'] = array_slice( $response_body['events'], 0, 3 );
     386                }
     387
     388                return $response_body;
     389        }
     390
     391        /**
     392         * Logs responses to Events API requests
     393         *
     394         * All responses are logged when debugging, even if they're not WP_Errors.
     395         * Debugging info is still needed for "successful" responses, because
     396         * the API might have returned a different location than the one the user
     397         * intended to receive. In those cases, knowing the exact `request_url` is
     398         * critical.
     399         *
     400         * Errors are logged instead of being triggered, to avoid breaking the JSON
     401         * response when called from AJAX handlers and `display_errors` is enabled.
     402         *
     403         * @access protected
     404         * @since 4.8.0
     405         *
     406         * @param string $message        A description of what occurred
     407         * @param array  $debugging_info Details that provide more context for the
     408         *                               log entry
     409         */
     410        protected function maybe_log_events_response( $message, $details ) {
     411                if ( ! WP_DEBUG_LOG ) {
     412                        return;
     413                }
     414
     415                error_log( sprintf(
     416                        '%s: %s. Details: %s',
     417                        __METHOD__,
     418                        trim( $message, '.' ),
     419                        wp_json_encode( $details )
     420                ) );
     421        }
     422}
  • src/wp-admin/includes/dashboard.php

    diff --git src/wp-admin/includes/dashboard.php src/wp-admin/includes/dashboard.php
    index be0b201c9f..270b633cbd 100644
    function wp_dashboard_setup() { 
    5252                wp_add_dashboard_widget( 'dashboard_quick_press', $quick_draft_title, 'wp_dashboard_quick_press' );
    5353        }
    5454
    55         // WordPress News
    56         wp_add_dashboard_widget( 'dashboard_primary', __( 'WordPress News' ), 'wp_dashboard_primary' );
     55        // WordPress Events and News
     56        wp_add_dashboard_widget( 'dashboard_primary', __( 'WordPress Events and News' ), 'wp_dashboard_events_news' );
    5757
    5858        if ( is_network_admin() ) {
    5959
    function wp_dashboard_setup() { 
    130130}
    131131
    132132/**
     133 * Gets the community events data that needs to be passed to dashboard.js
     134 *
     135 * @since 4.8.0
     136 *
     137 * @return array The script data.
     138 */
     139function wp_get_community_events_script_data() {
     140        require_once( ABSPATH . 'wp-admin/includes/class-wp-community-events.php' );
     141
     142        $user_id       = get_current_user_id();
     143        $user_location = get_user_option( 'community-events-location', $user_id );
     144        $events_client = new WP_Community_Events( $user_id, $user_location );
     145
     146        $script_data = array(
     147                'nonce' => wp_create_nonce( 'community_events' ),
     148                'cache' => $events_client->get_cached_events(),
     149
     150                'l10n' => array(
     151                        'enter_closest_city' => __( 'Enter your closest city to find nearby events.' ),
     152                        'error_occurred_please_try_again' => __( 'An error occured. Please try again.' ),
     153
     154                        /*
     155                         * These specific examples were chosen to highlight the fact that a
     156                         * state is not needed, even for cities whose name is not unique.
     157                         * It would be too cumbersome to include that in the instructions
     158                         * to the user, so it's left as an implication.
     159                         */
     160                        /* translators: %s is the name of the city we couldn't locate. Replace the examples with cities in your locale, but test that they match the expected location before including them. Use endonyms (native locale names) whenever possible. */
     161                        'could_not_locate_city' => __( "We couldn't locate %s. Please try another nearby city. For example: Kansas City; Springfield; Portland." ),
     162
     163                        // This one is only used with wp.a11y.speak(), so it can/should be more brief.
     164                        /* translators: %s is the name of a city. */
     165                        'city_updated' => __( 'City updated. Listing events near %s.' ),
     166                )
     167        );
     168
     169        return $script_data;
     170}
     171
     172/**
    133173 * Adds a new dashboard widget.
    134174 *
    135175 * @since 2.7.0
    function wp_dashboard_rss_control( $widget_id, $form_inputs = array() ) { 
    10691109        wp_widget_rss_form( $widget_options[$widget_id], $form_inputs );
    10701110}
    10711111
     1112
     1113/**
     1114 * Renders the Events and News dashboard widget
     1115 *
     1116 * @since 4.8.0
     1117 */
     1118function wp_dashboard_events_news() {
     1119        wp_print_community_events_markup();
     1120
     1121        ?>
     1122
     1123        <div class="wordpress-news hide-if-no-js">
     1124                <?php wp_dashboard_primary(); ?>
     1125        </div>
     1126
     1127        <p class="community-events-footer">
     1128                <a href="https://make.wordpress.org/community/meetups-landing-page" target="_blank">
     1129                        <?php _e( 'Meetups' ); ?> <span class="dashicons dashicons-external"></span>
     1130                </a>
     1131
     1132                |
     1133
     1134                <a href="https://central.wordcamp.org/schedule/" target="_blank">
     1135                        <?php _e( 'WordCamps' ); ?> <span class="dashicons dashicons-external"></span>
     1136                </a>
     1137
     1138                |
     1139
     1140                <?php // translators: If a Rosetta site exists (e.g. https://es.wordpress.org/news/), then use that. Otherwise, leave untranslated. ?>
     1141                <a href="<?php _e( 'https://wordpress.org/news/' ); ?>" target="_blank">
     1142                        <?php _e( 'News' ); ?> <span class="dashicons dashicons-external"></span>
     1143                </a>
     1144        </p>
     1145
     1146        <?php
     1147}
     1148
     1149/**
     1150 * Prints the markup for the Community Events section of the Events and News Dashboard widget
     1151 *
     1152 * @since 4.8.0
     1153 */
     1154function wp_print_community_events_markup() {
     1155        $script_data = wp_get_community_events_script_data();
     1156
     1157        ?>
     1158
     1159        <div class="community-events-errors notice notice-error inline hide-if-js">
     1160                <p class="hide-if-js">
     1161                        <?php _e( 'This widget requires JavaScript.'); ?>
     1162                </p>
     1163
     1164                <p class="community-events-error-occurred" aria-hidden="true">
     1165                        <?php echo $script_data['l10n']['error_occurred_please_try_again']; ?>
     1166                </p>
     1167
     1168                <p class="community-events-could-not-locate" aria-hidden="true"></p>
     1169        </div>
     1170
     1171        <div class="community-events-loading hide-if-no-js">
     1172                <?php _e( 'Loading&hellip;'); ?>
     1173        </div>
     1174
     1175        <?php
     1176        /*
     1177         * Hide the main element when the page first loads, because the content
     1178         * won't be ready until wp.communityEvents.renderEventsTemplate() has run.
     1179         */
     1180        ?>
     1181        <div id="community-events" class="community-events" aria-hidden="true">
     1182                <div class="activity-block">
     1183                        <p>
     1184                                <span id="community-events-location-message"></span>
     1185
     1186                                <button class="button-link community-events-toggle-location" aria-label="<?php _e( 'Edit city'); ?>" aria-expanded="false">
     1187                                        <span class="dashicons dashicons-edit"></span>
     1188                                </button>
     1189                        </p>
     1190
     1191                        <form class="community-events-form" aria-hidden="true" action="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>" method="post">
     1192                                <label for="community-events-location">
     1193                                        <?php _e( 'City:' ); ?>
     1194                                </label>
     1195                                <?php /* translators: Replace with the name of a city in your locale that shows events. Use only the city name itself, without any region or country. Use the endonym instead of the English name. */ ?>
     1196                                <input id="community-events-location" class="regular-text" type="text" name="community-events-location" placeholder="<?php _e( 'Cincinnati' ); ?>" />
     1197
     1198                                <?php submit_button( __( 'Submit' ), 'secondary', 'community-events-submit', false ); ?>
     1199
     1200                                <button class="community-events-cancel button button-link" type="button" aria-expanded="false">
     1201                                        <?php _e( 'Cancel' ); ?>
     1202                                </button>
     1203
     1204                                <span class="spinner"></span>
     1205                        </form>
     1206                </div>
     1207
     1208                <ul class="community-events-results activity-block last"></ul>
     1209        </div>
     1210
     1211        <?php
     1212}
     1213
     1214/**
     1215 * Renders the events templates for the Event and News widget
     1216 *
     1217 * @since 4.8.0
     1218 */
     1219function wp_print_community_events_templates() {
     1220        $script_data = wp_get_community_events_script_data();
     1221
     1222        ?>
     1223
     1224        <script id="tmpl-community-events-attend-event-near" type="text/template">
     1225                <?php printf(
     1226                        /* translators: %s is a placeholder for the name of a city. */
     1227                        __( 'Attend an upcoming event near %s.' ),
     1228                        '<strong>{{ data.location.description }}</strong>'
     1229                ); ?>
     1230        </script>
     1231
     1232        <script id="tmpl-community-events-could-not-locate" type="text/template">
     1233                <?php printf(
     1234                        $script_data['l10n']['could_not_locate_city'],
     1235                        '<em>{{data.unknownCity}}</em>'
     1236                ); ?>
     1237        </script>
     1238
     1239        <script id="tmpl-community-events-event-list" type="text/template">
     1240                <# _.each( data.events, function( event ) { #>
     1241                        <li class="event event-{{ event.type }} wp-clearfix">
     1242                                <div class="event-info">
     1243                                        <div class="dashicons event-icon" aria-hidden="true"></div>
     1244                                        <div class="event-info-inner">
     1245                                                <a class="event-title" href="{{ event.url }}">{{ event.title }}</a>
     1246                                                <span class="event-city">{{ event.location.location }}</span>
     1247                                        </div>
     1248                                </div>
     1249
     1250                                <div class="event-date-time">
     1251                                        <span class="event-date">{{ event.formatted_date }}</span>
     1252                                        <# if ( 'meetup' === event.type ) { #>
     1253                                                <span class="event-time">{{ event.formatted_time }}</span>
     1254                                        <# } #>
     1255                                </div>
     1256                        </li>
     1257                <# } ) #>
     1258        </script>
     1259
     1260        <script id="tmpl-community-events-no-upcoming-events" type="text/template">
     1261                <li class="event-none">
     1262                        <?php printf(
     1263                                /* translators: Replace the URL if a locale-specific one exists */
     1264                                __( 'There aren&#8217;t any events scheduled near %s at the moment. Would you like to <a href="https://make.wordpress.org/community/handbook/meetup-organizer/welcome/">organize one</a>?' ),
     1265                                '{{data.location.description}}'
     1266                        ); ?>
     1267                </li>
     1268        </script>
     1269
     1270        <?php
     1271}
     1272
    10721273/**
    10731274 * WordPress News dashboard widget.
    10741275 *
    function wp_dashboard_primary() { 
    11051306                         */
    11061307                        'title'        => apply_filters( 'dashboard_primary_title', __( 'WordPress Blog' ) ),
    11071308                        'items'        => 1,
    1108                         'show_summary' => 1,
     1309                        'show_summary' => 0,
    11091310                        'show_author'  => 0,
    1110                         'show_date'    => 1,
     1311                        'show_date'    => 0,
    11111312                ),
    11121313                'planet' => array(
    11131314
    function wp_dashboard_primary() { 
    11521353                )
    11531354        );
    11541355
    1155         if ( ( ! wp_disallow_file_mods( 'dashboard_widget' ) ) && ( ! is_multisite() && is_blog_admin() && current_user_can( 'install_plugins' ) ) || ( is_network_admin() && current_user_can( 'manage_network_plugins' ) && current_user_can( 'install_plugins' ) ) ) {
    1156                 $feeds['plugins'] = array(
    1157                         'link'         => '',
    1158                         'url'          => array(
    1159                                 'popular' => 'http://wordpress.org/plugins/rss/browse/popular/',
    1160                         ),
    1161                         'title'        => '',
    1162                         'items'        => 1,
    1163                         'show_summary' => 0,
    1164                         'show_author'  => 0,
    1165                         'show_date'    => 0,
    1166                 );
    1167         }
    1168 
    11691356        wp_dashboard_cached_rss_widget( 'dashboard_primary', 'wp_dashboard_primary_output', $feeds );
    11701357}
    11711358
    function wp_dashboard_primary_output( $widget_id, $feeds ) { 
    11811368        foreach ( $feeds as $type => $args ) {
    11821369                $args['type'] = $type;
    11831370                echo '<div class="rss-widget">';
    1184                 if ( $type === 'plugins' ) {
    1185                         wp_dashboard_plugins_output( $args['url'], $args );
    1186                 } else {
    11871371                        wp_widget_rss_output( $args['url'], $args );
    1188                 }
    11891372                echo "</div>";
    11901373        }
    11911374}
    11921375
    11931376/**
    1194  * Display plugins text for the WordPress news widget.
    1195  *
    1196  * @since 2.5.0
    1197  *
    1198  * @param string $rss  The RSS feed URL.
    1199  * @param array  $args Array of arguments for this RSS feed.
    1200  */
    1201 function wp_dashboard_plugins_output( $rss, $args = array() ) {
    1202         // Plugin feeds plus link to install them
    1203         $popular = fetch_feed( $args['url']['popular'] );
    1204 
    1205         if ( false === $plugin_slugs = get_transient( 'plugin_slugs' ) ) {
    1206                 $plugin_slugs = array_keys( get_plugins() );
    1207                 set_transient( 'plugin_slugs', $plugin_slugs, DAY_IN_SECONDS );
    1208         }
    1209 
    1210         echo '<ul>';
    1211 
    1212         foreach ( array( $popular ) as $feed ) {
    1213                 if ( is_wp_error( $feed ) || ! $feed->get_item_quantity() )
    1214                         continue;
    1215 
    1216                 $items = $feed->get_items(0, 5);
    1217 
    1218                 // Pick a random, non-installed plugin
    1219                 while ( true ) {
    1220                         // Abort this foreach loop iteration if there's no plugins left of this type
    1221                         if ( 0 == count($items) )
    1222                                 continue 2;
    1223 
    1224                         $item_key = array_rand($items);
    1225                         $item = $items[$item_key];
    1226 
    1227                         list($link, $frag) = explode( '#', $item->get_link() );
    1228 
    1229                         $link = esc_url($link);
    1230                         if ( preg_match( '|/([^/]+?)/?$|', $link, $matches ) )
    1231                                 $slug = $matches[1];
    1232                         else {
    1233                                 unset( $items[$item_key] );
    1234                                 continue;
    1235                         }
    1236 
    1237                         // Is this random plugin's slug already installed? If so, try again.
    1238                         reset( $plugin_slugs );
    1239                         foreach ( $plugin_slugs as $plugin_slug ) {
    1240                                 if ( $slug == substr( $plugin_slug, 0, strlen( $slug ) ) ) {
    1241                                         unset( $items[$item_key] );
    1242                                         continue 2;
    1243                                 }
    1244                         }
    1245 
    1246                         // If we get to this point, then the random plugin isn't installed and we can stop the while().
    1247                         break;
    1248                 }
    1249 
    1250                 // Eliminate some common badly formed plugin descriptions
    1251                 while ( ( null !== $item_key = array_rand($items) ) && false !== strpos( $items[$item_key]->get_description(), 'Plugin Name:' ) )
    1252                         unset($items[$item_key]);
    1253 
    1254                 if ( !isset($items[$item_key]) )
    1255                         continue;
    1256 
    1257                 $raw_title = $item->get_title();
    1258 
    1259                 $ilink = wp_nonce_url('plugin-install.php?tab=plugin-information&plugin=' . $slug, 'install-plugin_' . $slug) . '&amp;TB_iframe=true&amp;width=600&amp;height=800';
    1260                 echo '<li class="dashboard-news-plugin"><span>' . __( 'Popular Plugin' ) . ':</span> ' . esc_html( $raw_title ) .
    1261                         '&nbsp;<a href="' . $ilink . '" class="thickbox open-plugin-details-modal" aria-label="' .
    1262                         /* translators: %s: plugin name */
    1263                         esc_attr( sprintf( __( 'Install %s' ), $raw_title ) ) . '">(' . __( 'Install' ) . ')</a></li>';
    1264 
    1265                 $feed->__destruct();
    1266                 unset( $feed );
    1267         }
    1268 
    1269         echo '</ul>';
    1270 }
    1271 
    1272 /**
    12731377 * Display file upload quota on dashboard.
    12741378 *
    12751379 * Runs on the {@see 'activity_box_end'} hook in wp_dashboard_right_now().
  • src/wp-admin/includes/deprecated.php

    diff --git src/wp-admin/includes/deprecated.php src/wp-admin/includes/deprecated.php
    index 2bf25d3336..eb34231168 100644
    function wp_dashboard_secondary() {} 
    12951295function wp_dashboard_secondary_control() {}
    12961296
    12971297/**
     1298 * Display plugins text for the WordPress news widget.
     1299 *
     1300 * @since 2.5.0
     1301 * @deprecated 4.8.0
     1302 *
     1303 * @param string $rss  The RSS feed URL.
     1304 * @param array  $args Array of arguments for this RSS feed.
     1305 */
     1306function wp_dashboard_plugins_output( $rss, $args = array() ) {
     1307        _deprecated_function( __FUNCTION__, '4.8.0' );
     1308
     1309        // Plugin feeds plus link to install them
     1310        $popular = fetch_feed( $args['url']['popular'] );
     1311
     1312        if ( false === $plugin_slugs = get_transient( 'plugin_slugs' ) ) {
     1313                $plugin_slugs = array_keys( get_plugins() );
     1314                set_transient( 'plugin_slugs', $plugin_slugs, DAY_IN_SECONDS );
     1315        }
     1316
     1317        echo '<ul>';
     1318
     1319        foreach ( array( $popular ) as $feed ) {
     1320                if ( is_wp_error( $feed ) || ! $feed->get_item_quantity() )
     1321                        continue;
     1322
     1323                $items = $feed->get_items(0, 5);
     1324
     1325                // Pick a random, non-installed plugin
     1326                while ( true ) {
     1327                        // Abort this foreach loop iteration if there's no plugins left of this type
     1328                        if ( 0 == count($items) )
     1329                                continue 2;
     1330
     1331                        $item_key = array_rand($items);
     1332                        $item = $items[$item_key];
     1333
     1334                        list($link, $frag) = explode( '#', $item->get_link() );
     1335
     1336                        $link = esc_url($link);
     1337                        if ( preg_match( '|/([^/]+?)/?$|', $link, $matches ) )
     1338                                $slug = $matches[1];
     1339                        else {
     1340                                unset( $items[$item_key] );
     1341                                continue;
     1342                        }
     1343
     1344                        // Is this random plugin's slug already installed? If so, try again.
     1345                        reset( $plugin_slugs );
     1346                        foreach ( $plugin_slugs as $plugin_slug ) {
     1347                                if ( $slug == substr( $plugin_slug, 0, strlen( $slug ) ) ) {
     1348                                        unset( $items[$item_key] );
     1349                                        continue 2;
     1350                                }
     1351                        }
     1352
     1353                        // If we get to this point, then the random plugin isn't installed and we can stop the while().
     1354                        break;
     1355                }
     1356
     1357                // Eliminate some common badly formed plugin descriptions
     1358                while ( ( null !== $item_key = array_rand($items) ) && false !== strpos( $items[$item_key]->get_description(), 'Plugin Name:' ) )
     1359                        unset($items[$item_key]);
     1360
     1361                if ( !isset($items[$item_key]) )
     1362                        continue;
     1363
     1364                $raw_title = $item->get_title();
     1365
     1366                $ilink = wp_nonce_url('plugin-install.php?tab=plugin-information&plugin=' . $slug, 'install-plugin_' . $slug) . '&amp;TB_iframe=true&amp;width=600&amp;height=800';
     1367                echo '<li class="dashboard-news-plugin"><span>' . __( 'Popular Plugin' ) . ':</span> ' . esc_html( $raw_title ) .
     1368                        '&nbsp;<a href="' . $ilink . '" class="thickbox open-plugin-details-modal" aria-label="' .
     1369                        /* translators: %s: plugin name */
     1370                        esc_attr( sprintf( __( 'Install %s' ), $raw_title ) ) . '">(' . __( 'Install' ) . ')</a></li>';
     1371
     1372                $feed->__destruct();
     1373                unset( $feed );
     1374        }
     1375
     1376        echo '</ul>';
     1377}
     1378
     1379/**
     1380 * noop's the Nearby WordPress Events feature plugin bootstrap process
     1381 *
     1382 * This function was never used in Core, but it's necessary to prevent the
     1383 * feature plugin from bootstrapping now that its functionality has been merged
     1384 * into Core.
     1385 *
     1386 * See https://plugins.trac.wordpress.org/browser/nearby-wp-events/tags/0.7/nearby-wordpress-events.php?marks=58-87#L55
     1387 *
     1388 * @deprecated 4.8.0
     1389 */
     1390function wp_get_nearby_events() {}
     1391
     1392/**
    12981393 * This was once used to move child posts to a new parent.
    12991394 *
    13001395 * @since 2.3.0
  • src/wp-admin/includes/upgrade.php

    diff --git src/wp-admin/includes/upgrade.php src/wp-admin/includes/upgrade.php
    index 94ad771761..d4be87dfaa 100644
    function upgrade_all() { 
    565565        if ( $wp_current_db_version < 37965 )
    566566                upgrade_460();
    567567
     568        if ( $wp_current_db_version < 40500 ) { //todo update to commit for #40702
     569                upgrade_480();
     570        }
     571
    568572        maybe_disable_link_manager();
    569573
    570574        maybe_disable_automattic_widgets();
    function upgrade_460() { 
    17331737}
    17341738
    17351739/**
     1740 * Executes changes made in WordPress 4.8.0.
     1741 *
     1742 * @ignore
     1743 * @since 4.8.0
     1744 *
     1745 * @global int $wp_current_db_version Current database version.
     1746 */
     1747function upgrade_480() {
     1748        global $wp_current_db_version;
     1749
     1750        if ( $wp_current_db_version < 40500 ) { // todo update to commit for #40702
     1751                // This feature plugin was merged for #40702, so the plugin itself is no longer needed
     1752                deactivate_plugins( array( 'nearby-wp-events/nearby-wordpress-events.php' ), true );
     1753
     1754                // The markup stored in this transient changed for #40702
     1755                delete_transient( 'dash_' . md5( 'dashboard_primary' . '_' . get_locale() ) );
     1756        }
     1757}
     1758
     1759/**
    17361760 * Executes network-level upgrade routines.
    17371761 *
    17381762 * @since 3.0.0
  • src/wp-admin/index.php

    diff --git src/wp-admin/index.php src/wp-admin/index.php
    index d2d7ec889d..76628abbd7 100644
    require_once(ABSPATH . 'wp-admin/includes/dashboard.php'); 
    1515wp_dashboard_setup();
    1616
    1717wp_enqueue_script( 'dashboard' );
     18wp_localize_script( 'dashboard', 'communityEventsData', wp_get_community_events_script_data() );
     19
    1820if ( current_user_can( 'edit_theme_options' ) )
    1921        wp_enqueue_script( 'customize-loader' );
    2022if ( current_user_can( 'install_plugins' ) ) {
    include( ABSPATH . 'wp-admin/admin-header.php' ); 
    138140</div><!-- wrap -->
    139141
    140142<?php
     143wp_print_community_events_templates();
     144
    141145require( ABSPATH . 'wp-admin/admin-footer.php' );
  • src/wp-admin/js/dashboard.js

    diff --git src/wp-admin/js/dashboard.js src/wp-admin/js/dashboard.js
    index fa100dd16c..e4488693f1 100644
     
    1 /* global pagenow, ajaxurl, postboxes, wpActiveEditor:true */
     1/* global pagenow, ajaxurl, postboxes, wpActiveEditor:true, communityEventsData */
    22var ajaxWidgets, ajaxPopulateWidgets, quickPressLoad;
    33
    44jQuery(document).ready( function($) {
    jQuery(document).ready( function($) { 
    187187        }
    188188
    189189} );
     190
     191
     192wp.communityEvents = wp.communityEvents || {};
     193
     194jQuery( function( $ ) {
     195        'use strict';
     196
     197        var app = wp.communityEvents = {
     198                initialized: false,
     199                model: null,
     200
     201                /**
     202                 * Initializes the wp.communityEvents object
     203                 *
     204                 * @since 4.8.0
     205                 */
     206                init: function() {
     207                        if ( app.initialized ) {
     208                                return;
     209                        }
     210
     211                        var $container = $( '#community-events' );
     212
     213                        /*
     214                         * When JavaScript is disabled, the errors container is shown, so
     215                         * that "This widget requires Javascript" message can be seen.
     216                         *
     217                         * When JS is enabled, the container is hidden at first, and then
     218                         * revealed during the template rendering, if there actually are
     219                         * errors to show.
     220                         *
     221                         * The display indicator switches from `hide-if-js` to `aria-hidden`
     222                         * here in order to maintain consistency with all the other fields
     223                         * that key off of `aria-hidden` to determine their visibility.
     224                         * `aria-hidden` can't be used initially, because there would be no
     225                         * way to set it to false when JavaScript is disabled, which would
     226                         * prevent people from seeing the "This widget requires JavaScript"
     227                         * message.
     228                         */
     229                        $( '.community-events-errors' )
     230                                .attr( 'aria-hidden', true )
     231                                .removeClass( 'hide-if-js' );
     232
     233                        $container.on( 'click', '.community-events-toggle-location', app.toggleLocationForm );
     234                        $container.on( 'click', '.community-events-cancel', app.toggleLocationForm );
     235
     236                        $container.on( 'submit', '.community-events-form', function( event ) {
     237                                event.preventDefault();
     238
     239                                app.getEvents( {
     240                                        location: $( '#community-events-location' ).val()
     241                                });
     242                        });
     243
     244                        if ( communityEventsData && communityEventsData.cache && communityEventsData.cache.location && communityEventsData.cache.events ) {
     245                                app.renderEventsTemplate( communityEventsData.cache, 'app' );
     246                        } else {
     247                                app.getEvents();
     248                        }
     249
     250                        app.initialized = true;
     251                },
     252
     253                /**
     254                 * Toggles the visibility of the Edit Location form
     255                 *
     256                 * @since 4.8.0
     257                 *
     258                 * @param {event|string} action 'show' or 'hide' to specify a state;
     259                 *                              Or an event object to flip between states
     260                 */
     261                toggleLocationForm: function( action ) {
     262                        var $toggleButton = $( '.community-events-toggle-location' ),
     263                            $cancelButton = $( '.community-events-cancel' ),
     264                            $form         = $( '.community-events-form' );
     265
     266                        if ( 'object' === typeof action ) {
     267                                // Strict comparison doesn't work in this case.
     268                                action = 'true' == $toggleButton.attr( 'aria-expanded' ) ? 'hide' : 'show';
     269                        }
     270
     271                        if ( 'hide' === action ) {
     272                                $toggleButton.attr( 'aria-expanded', false );
     273                                $cancelButton.attr( 'aria-expanded', false );
     274                                $form.attr( 'aria-hidden', true );
     275                        } else {
     276                                $toggleButton.attr( 'aria-expanded', true );
     277                                $cancelButton.attr( 'aria-expanded', true );
     278                                $form.attr( 'aria-hidden', false );
     279                        }
     280                },
     281
     282                /**
     283                 * Sends REST API requests to fetch events for the widget
     284                 *
     285                 * @since 4.8.0
     286                 *
     287                 * @param {object} requestParams
     288                 */
     289                getEvents: function( requestParams ) {
     290                        var initiatedBy,
     291                            app = this,
     292                            $spinner = $( '.community-events-form' ).children( '.spinner' );
     293
     294                        requestParams          = requestParams || {};
     295                        requestParams._wpnonce = communityEventsData.nonce;
     296                        requestParams.timezone = window.Intl ? window.Intl.DateTimeFormat().resolvedOptions().timeZone : '';
     297
     298                        initiatedBy = requestParams.location ? 'user' : 'app';
     299
     300                        $spinner.addClass( 'is-active' );
     301
     302                        wp.ajax.post( 'get-community-events', requestParams )
     303                                .always( function() {
     304                                        $spinner.removeClass( 'is-active' );
     305                                })
     306
     307                                .done( function( response ) {
     308                                        if ( 'no_location_available' === response.error ) {
     309                                                if ( requestParams.location ) {
     310                                                        response.unknownCity = requestParams.location;
     311                                                } else {
     312                                                        /*
     313                                                         * No location was passed, which means that this was an automatic query
     314                                                         * based on IP, locale, and timezone. Since the user didn't initiate it,
     315                                                         * it should fail silently. Otherwise, the error could confuse and/or
     316                                                         * annoy them.
     317                                                         */
     318
     319                                                        delete response.error;
     320                                                }
     321                                        }
     322                                        app.renderEventsTemplate( response, initiatedBy );
     323                                })
     324
     325                                .fail( function() {
     326                                        app.renderEventsTemplate( {
     327                                                'location' : false,
     328                                                'error'    : true
     329                                        }, initiatedBy );
     330                                });
     331                },
     332
     333                /**
     334                 * Renders the template for the Events section of the Events & News widget
     335                 *
     336                 * @since 4.8.0
     337                 *
     338                 * @param {Object} templateParams The various parameters that will get passed to wp.template
     339                 * @param {string} initiatedBy    'user' to indicate that this was triggered manually by the user;
     340                 *                                'app' to indicate it was triggered automatically by the app itself.
     341                 */
     342                renderEventsTemplate: function( templateParams, initiatedBy ) {
     343                        var template,
     344                            elementVisibility,
     345                            l10nPlaceholder  = /%(?:\d\$)?s/g, // Match `%s`, `%1$s`, `%2$s`, etc.
     346                            $locationMessage = $( '#community-events-location-message' ),
     347                            $results         = $( '.community-events-results' );
     348
     349                        /*
     350                         * Hide all toggleable elements by default, to keep the logic simple.
     351                         * Otherwise, each block below would have to turn hide everything that
     352                         * could have been shown at an earlier point.
     353                         *
     354                         * The exception to that is that the .community-events container. It's hidden
     355                         * when the page is first loaded, because the content isn't ready yet,
     356                         * but once we've reached this point, it should always be shown.
     357                         */
     358                        elementVisibility = {
     359                                '.community-events'                  : true,
     360                                '.community-events-loading'          : false,
     361                                '.community-events-errors'           : false,
     362                                '.community-events-error-occurred'   : false,
     363                                '.community-events-could-not-locate' : false,
     364                                '#community-events-location-message' : false,
     365                                '.community-events-toggle-location'  : false,
     366                                '.community-events-results'          : false
     367                        };
     368
     369                        /*
     370                         * Determine which templates should be rendered and which elements
     371                         * should be displayed
     372                         */
     373                        if ( templateParams.location && templateParams.location.description ) {
     374                                template = wp.template( 'community-events-attend-event-near' );
     375                                $locationMessage.html( template( templateParams ) );
     376
     377                                if ( templateParams.events.length ) {
     378                                        template = wp.template( 'community-events-event-list' );
     379                                        $results.html( template( templateParams ) );
     380                                } else {
     381                                        template = wp.template( 'community-events-no-upcoming-events' );
     382                                        $results.html( template( templateParams ) );
     383                                }
     384                                wp.a11y.speak( communityEventsData.l10n.city_updated.replace( l10nPlaceholder, templateParams.location.description ) );
     385
     386                                elementVisibility['#community-events-location-message'] = true;
     387                                elementVisibility['.community-events-toggle-location']  = true;
     388                                elementVisibility['.community-events-results']          = true;
     389
     390                        } else if ( templateParams.unknownCity ) {
     391                                template = wp.template( 'community-events-could-not-locate' );
     392                                $( '.community-events-could-not-locate' ).html( template( templateParams ) );
     393                                wp.a11y.speak( communityEventsData.l10n.could_not_locate_city.replace( l10nPlaceholder, templateParams.unknownCity ) );
     394
     395                                elementVisibility['.community-events-errors']           = true;
     396                                elementVisibility['.community-events-could-not-locate'] = true;
     397
     398                        } else if ( templateParams.error && 'user' === initiatedBy ) {
     399                                /*
     400                                 * Errors messages are only shown for requests that were initiated
     401                                 * by the user, not for ones that were initiated by the app itself.
     402                                 * Showing error messages for an event that user isn't aware of
     403                                 * could be confusing or unnecessarily distracting.
     404                                 */
     405                                wp.a11y.speak( communityEventsData.l10n.error_occurred_please_try_again );
     406
     407                                elementVisibility['.community-events-errors']         = true;
     408                                elementVisibility['.community-events-error-occurred'] = true;
     409
     410                        } else {
     411                                $locationMessage.text( communityEventsData.l10n.enter_closest_city );
     412
     413                                elementVisibility['#community-events-location-message'] = true;
     414                                elementVisibility['.community-events-toggle-location']  = true;
     415                        }
     416
     417                        // Set the visibility of toggleable elements.
     418                        _.each( elementVisibility, function( isVisible, element ) {
     419                                $( element ).attr( 'aria-hidden', ! isVisible );
     420                        });
     421
     422                        $( '.community-events-toggle-location' ).attr( 'aria-expanded', elementVisibility['.community-events-toggle-location'] );
     423
     424                        /*
     425                         * During the initial page load, the location form should be hidden
     426                         * by default if the user has saved a valid location during a previous
     427                         * session. It's safe to assume that they want to continue using that
     428                         * location, and displaying the form would unnecessarily clutter the
     429                         * widget.
     430                         */
     431                        if ( 'app' === initiatedBy && templateParams.location.description ) {
     432                                app.toggleLocationForm( 'hide' );
     433                        } else {
     434                                app.toggleLocationForm( 'show' );
     435                        }
     436                }
     437        };
     438
     439        if ( $( '#dashboard_primary' ).is( ':visible' ) ) {
     440                app.init();
     441        } else {
     442                $( document ).on( 'postbox-toggled', function( event, postbox ) {
     443                        var $postbox = $( postbox );
     444
     445                        if ( 'dashboard_primary' === $postbox.attr( 'id' ) && $postbox.is( ':visible' ) ) {
     446                                app.init();
     447                        }
     448                });
     449        }
     450});
  • src/wp-admin/network/index.php

    diff --git src/wp-admin/network/index.php src/wp-admin/network/index.php
    index 81ededbe37..38acec4b6e 100644
    get_current_screen()->set_help_sidebar( 
    5454wp_dashboard_setup();
    5555
    5656wp_enqueue_script( 'dashboard' );
     57wp_localize_script( 'dashboard', 'communityEventsData', wp_get_community_events_script_data() );
    5758wp_enqueue_script( 'plugin-install' );
    5859add_thickbox();
    5960
    require_once( ABSPATH . 'wp-admin/admin-header.php' ); 
    7374
    7475</div><!-- wrap -->
    7576
    76 <?php include( ABSPATH . 'wp-admin/admin-footer.php' ); ?>
     77<?php
     78wp_print_community_events_templates();
     79include( ABSPATH . 'wp-admin/admin-footer.php' );
  • src/wp-includes/script-loader.php

    diff --git src/wp-includes/script-loader.php src/wp-includes/script-loader.php
    index def438c260..43209721f5 100644
    function wp_default_scripts( &$scripts ) { 
    724724                        'current' => __( 'Current Color' ),
    725725                ) );
    726726
    727                 $scripts->add( 'dashboard', "/wp-admin/js/dashboard$suffix.js", array( 'jquery', 'admin-comments', 'postbox' ), false, 1 );
     727                $scripts->add( 'dashboard', "/wp-admin/js/dashboard$suffix.js", array( 'jquery', 'admin-comments', 'postbox', 'wp-util', 'wp-a11y' ), false, 1 );
    728728
    729729                $scripts->add( 'list-revisions', "/wp-includes/js/wp-list-revisions$suffix.js" );
    730730
  • new file tests/phpunit/tests/admin/includesCommunityEvents.php

    diff --git tests/phpunit/tests/admin/includesCommunityEvents.php tests/phpunit/tests/admin/includesCommunityEvents.php
    new file mode 100644
    index 0000000000..09f8685d62
    - +  
     1<?php
     2/**
     3 * Unit tests for methods in WP_Community_Events.
     4 *
     5 * @package WordPress
     6 * @subpackage UnitTests
     7 * @since 4.8.0
     8 */
     9
     10/**
     11 * Class Test_WP_Community_Events.
     12 *
     13 * @group admin
     14 * @group community-events
     15 *
     16 * @since 4.8.0
     17 */
     18class Test_WP_Community_Events extends WP_UnitTestCase {
     19        /**
     20         * An instance of the class to test.
     21         *
     22         * @access private
     23         * @since 4.8.0
     24         *
     25         * @var WP_Community_Events
     26         */
     27        private $instance;
     28
     29        /**
     30         * Performs setup tasks for every test.
     31         *
     32         * @access public
     33         * @since 4.8.0
     34         */
     35        public function setUp() {
     36                parent::setUp();
     37
     38                require_once( ABSPATH . 'wp-admin/includes/class-wp-community-events.php' );
     39
     40                $this->instance = new WP_Community_Events( 1, $this->get_user_location() );
     41        }
     42
     43        /**
     44         * Simulates a stored user location.
     45         *
     46         * @access private
     47         * @since 4.8.0
     48         *
     49         * @return array The mock location.
     50         */
     51        private function get_user_location() {
     52                return array(
     53                        'description' => 'San Francisco',
     54                        'latitude'    => '37.7749300',
     55                        'longitude'   => '-122.4194200',
     56                        'country'     => 'US',
     57                );
     58        }
     59
     60        /**
     61         * Test: `get_events()` should return an instance of WP_Error if the response code is not 200.
     62         *
     63         * @access public
     64         * @since 4.8.0
     65         */
     66        public function test_get_events_bad_response_code() {
     67                add_filter( 'pre_http_request', array( $this, '_http_request_bad_response_code' ) );
     68
     69                $this->assertWPError( $this->instance->get_events() );
     70
     71                remove_filter( 'pre_http_request', array( $this, '_http_request_bad_response_code' ) );
     72        }
     73
     74        /**
     75         * Test: The response body should not be cached if the response code is not 200.
     76         *
     77         * @access public
     78         * @since 4.8.0
     79         */
     80        public function test_get_cached_events_bad_response_code() {
     81                add_filter( 'pre_http_request', array( $this, '_http_request_bad_response_code' ) );
     82
     83                $this->instance->get_events();
     84
     85                $this->assertFalse( $this->instance->get_cached_events() );
     86
     87                remove_filter( 'pre_http_request', array( $this, '_http_request_bad_response_code' ) );
     88        }
     89
     90        /**
     91         * Simulates an HTTP response with a non-200 response code.
     92         *
     93         * @access public
     94         * @since 4.8.0
     95         *
     96         * @return array A mock response with a 404 HTTP status code
     97         */
     98        public function _http_request_bad_response_code() {
     99                return array(
     100                        'headers'  => '',
     101                        'body'     => '',
     102                        'response' => array(
     103                                'code' => 404,
     104                        ),
     105                        'cookies'  => '',
     106                        'filename' => '',
     107                );
     108        }
     109
     110        /**
     111         * Test: `get_events()` should return an instance of WP_Error if the response body does not have
     112         * the required properties.
     113         *
     114         * @access public
     115         * @since 4.8.0
     116         */
     117        public function test_get_events_invalid_response() {
     118                add_filter( 'pre_http_request', array( $this, '_http_request_invalid_response' ) );
     119
     120                $this->assertWPError( $this->instance->get_events() );
     121
     122                remove_filter( 'pre_http_request', array( $this, '_http_request_invalid_response' ) );
     123        }
     124
     125        /**
     126         * Test: The response body should not be cached if it does not have the required properties.
     127         *
     128         * @access public
     129         * @since 4.8.0
     130         */
     131        public function test_get_cached_events_invalid_response() {
     132                add_filter( 'pre_http_request', array( $this, '_http_request_invalid_response' ) );
     133
     134                $this->instance->get_events();
     135
     136                $this->assertFalse( $this->instance->get_cached_events() );
     137
     138                remove_filter( 'pre_http_request', array( $this, '_http_request_invalid_response' ) );
     139        }
     140
     141        /**
     142         * Simulates an HTTP response with a body that does not have the required properties.
     143         *
     144         * @access public
     145         * @since 4.8.0
     146         *
     147         * @return array A mock response that's missing required properties.
     148         */
     149        public function _http_request_invalid_response() {
     150                return array(
     151                        'headers'  => '',
     152                        'body'     => wp_json_encode( array() ),
     153                        'response' => array(
     154                                'code' => 200,
     155                        ),
     156                        'cookies'  => '',
     157                        'filename' => '',
     158                );
     159        }
     160
     161        /**
     162         * Test: With a valid response, `get_events()` should return an associated array containing a location array and
     163         * an events array with individual events that have formatted time and date.
     164         *
     165         * @access public
     166         * @since 4.8.0
     167         */
     168        public function test_get_events_valid_response() {
     169                add_filter( 'pre_http_request', array( $this, '_http_request_valid_response' ) );
     170
     171                $response = $this->instance->get_events();
     172
     173                $this->assertNotWPError( $response );
     174                $this->assertEqualSetsWithIndex( $this->get_user_location(), $response['location'] );
     175                $this->assertEquals( date( 'l, M j, Y', strtotime( 'next Sunday 1pm' ) ), $response['events'][0]['formatted_date'] );
     176                $this->assertEquals( '1:00 pm', $response['events'][0]['formatted_time'] );
     177
     178                remove_filter( 'pre_http_request', array( $this, '_http_request_valid_response' ) );
     179        }
     180
     181        /**
     182         * Test: `get_cached_events()` should return the same data as `get_events()`, including formatted time
     183         * and date values for each event.
     184         *
     185         * @access public
     186         * @since 4.8.0
     187         */
     188        public function test_get_cached_events_valid_response() {
     189                add_filter( 'pre_http_request', array( $this, '_http_request_valid_response' ) );
     190
     191                $this->instance->get_events();
     192
     193                $cached_events = $this->instance->get_cached_events();
     194
     195                $this->assertNotWPError( $cached_events );
     196                $this->assertEqualSetsWithIndex( $this->get_user_location(), $cached_events['location'] );
     197                $this->assertEquals( date( 'l, M j, Y', strtotime( 'next Sunday 1pm' ) ), $cached_events['events'][0]['formatted_date'] );
     198                $this->assertEquals( '1:00 pm', $cached_events['events'][0]['formatted_time'] );
     199
     200                remove_filter( 'pre_http_request', array( $this, '_http_request_valid_response' ) );
     201        }
     202
     203        /**
     204         * Simulates an HTTP response with valid location and event data.
     205         *
     206         * @access public
     207         * @since 4.8.0
     208         *
     209         * @return array A mock HTTP response with valid data.
     210         */
     211        public function _http_request_valid_response() {
     212                return array(
     213                        'headers'  => '',
     214                        'body'     => wp_json_encode( array(
     215                                'location' => $this->get_user_location(),
     216                                'events'   => array(
     217                                        array(
     218                                                'type'           => 'meetup',
     219                                                'title'          => 'Flexbox + CSS Grid: Magic for Responsive Layouts',
     220                                                'url'            => 'https://www.meetup.com/Eastbay-WordPress-Meetup/events/236031233/',
     221                                                'meetup'         => 'The East Bay WordPress Meetup Group',
     222                                                'meetup_url'     => 'https://www.meetup.com/Eastbay-WordPress-Meetup/',
     223                                                'date'           => date( 'Y-m-d H:i:s', strtotime( 'next Sunday 1pm' ) ),
     224                                                'location'       => array(
     225                                                        'location'  => 'Oakland, CA, USA',
     226                                                        'country'   => 'us',
     227                                                        'latitude'  => 37.808453,
     228                                                        'longitude' => -122.26593,
     229                                                ),
     230                                        ),
     231                                        array(
     232                                                'type'           => 'meetup',
     233                                                'title'          => 'Part 3- Site Maintenance - Tools to Make It Easy',
     234                                                'url'            => 'https://www.meetup.com/Wordpress-Bay-Area-CA-Foothills/events/237706839/',
     235                                                'meetup'         => 'WordPress Bay Area Foothills Group',
     236                                                'meetup_url'     => 'https://www.meetup.com/Wordpress-Bay-Area-CA-Foothills/',
     237                                                'date'           => date( 'Y-m-d H:i:s', strtotime( 'next Wednesday 1:30pm' ) ),
     238                                                'location'       => array(
     239                                                        'location'  => 'Milpitas, CA, USA',
     240                                                        'country'   => 'us',
     241                                                        'latitude'  => 37.432813,
     242                                                        'longitude' => -121.907095,
     243                                                ),
     244                                        ),
     245                                        array(
     246                                                'type'           => 'wordcamp',
     247                                                'title'          => 'WordCamp Kansas City',
     248                                                'url'            => 'https://2017.kansascity.wordcamp.org',
     249                                                'meetup'         => null,
     250                                                'meetup_url'     => null,
     251                                                'date'           => date( 'Y-m-d H:i:s', strtotime( 'next Saturday' ) ),
     252                                                'location'       => array(
     253                                                        'location'  => 'Kansas City, MO',
     254                                                        'country'   => 'US',
     255                                                        'latitude'  => 39.0392325,
     256                                                        'longitude' => -94.577076,
     257                                                ),
     258                                        ),
     259                                ),
     260                        ) ),
     261                        'response' => array(
     262                                'code' => 200,
     263                        ),
     264                        'cookies'  => '',
     265                        'filename' => '',
     266                );
     267        }
     268}