diff --git a/.circleci/deploy-exclude.txt b/.circleci/deploy-exclude.txt index 0248edd7..5e9f300f 100755 --- a/.circleci/deploy-exclude.txt +++ b/.circleci/deploy-exclude.txt @@ -10,5 +10,4 @@ node_modules package.json package-lock.json -src webpack.config.js diff --git a/.circleci/deploy.sh b/.circleci/deploy.sh index 7edb89b3..0a33223a 100755 --- a/.circleci/deploy.sh +++ b/.circleci/deploy.sh @@ -62,11 +62,6 @@ fi rsync -av "$SRC_DIR/" "$BUILD_DIR" --exclude-from "$SRC_DIR/.circleci/deploy-exclude.txt" -# Swap commit placeholders -SCRIPT_HASH=$(md5sum build/analytics.js | awk '{ print $1 }') -find . -name '*.php' | xargs sed -i.bak 's/__SCRIPT_HASH__/'"$SCRIPT_HASH"'/g' -find . -name '*.bak' -delete - # Add changed files git add . diff --git a/.eslintrc b/.eslintrc index 2fb769cb..970e504e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,7 +1,8 @@ { "extends": "humanmade", "globals": { - "Altis": "readonly" + "Altis": "readonly", + "wp": "readonly" }, "rules": { "no-multi-str": "off", diff --git a/README.md b/README.md index 5d06c88a..368664f5 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ Similar to `registerAttribute()` above but for metrics. Allows you to define the Elasticsearch server URL directly. +**`ALTIS_ANALYTICS_LOG_QUERIES`** + +Define as true to enable logging queries to the error log. + ### Filters The plugin provides a few hooks for you to control the default endpoint data and attributes recorded with events. @@ -131,6 +135,9 @@ A user session covers every event recorded between opening the website and closi - `ModelVersion`: Browser version. - `Platform`: The device operating system. - `PlatformVersion`: The operating system version. + - `Location` + - `Country`: The endpoint's country if known / available. + - `City`: The endpoint's city if known or available. - `User` - `UserAttributes` - Any custom attributes associated with the user if known. @@ -224,6 +231,28 @@ The output will look something like the following: You can further trim the size of the returned response using the `filter_path` query parameter. For example if we're only interested in the stats aggregation we can set `filter_path=-aggregations.sessions` to remove it from the response. +## Audiences + +Audiences are user-defined categories of users, based on conditions related to their analytics data. + +Audiences allow for the creation of conditions to narrow down event queries or endpoints but also can be used for determining effects on the client side. + +### Mapping Event Data + +To enable the use of any event record data in the audience editor it needs to be mapped to a human readable label using the `Altis\Analytics\Audiences\register_field()` function: + +```php +use function Altis\Analytics\Audiences\register_field; + +add_action( 'init', function () { + register_field( 'endpoint.Location.City', __( 'City' ) ); +} ); +``` + +In the above example the 1st parameter `endpoint.Location.City` represents the field in the event record to query against. Other examples include `attributes.utm_campaign` or `endpoint.User.UserAttibrutes.custom` for example. + +The 2nd parameter is a human readable label for the audience field. + ## Required Infrastructure A specific infrastructure set up is required to use this plugin: diff --git a/composer.json b/composer.json index baa21d74..c76ef065 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,8 @@ "description": "AWS Pinpoint Analytics integration for WordPress", "type": "wordpress-plugin", "require": { - "aws/aws-sdk-php": "^3.103.0" + "aws/aws-sdk-php": "^3.103.0", + "johnbillion/extended-cpts": "^4.3.2" }, "license": "GPL-2.0-or-later", "authors": [ diff --git a/inc/audiences/namespace.php b/inc/audiences/namespace.php new file mode 100644 index 00000000..4aca3d46 --- /dev/null +++ b/inc/audiences/namespace.php @@ -0,0 +1,757 @@ + false, + 'show_ui' => true, + 'supports' => [ + 'title', + 'excerpt', + ], + 'menu_icon' => 'dashicons-groups', + 'menu_position' => 151, + 'show_in_rest' => true, + 'rest_base' => 'audiences', + 'hierarchical' => false, + 'admin_cols' => [ + 'active' => [ + 'title' => __( 'Status', 'altis-analytics' ), + 'function' => __NAMESPACE__ . '\\render_status_column', + ], + 'estimate' => [ + 'title' => __( 'Size', 'altis-analytics' ), + 'function' => __NAMESPACE__ . '\\estimate_ui', + ], + 'last_modified' => [ + 'title' => __( 'Last Modified', 'altis-analytics' ), + 'post_field' => 'post_modified', + ], + ], + ], + [ + 'singular' => __( 'Audience', 'altis-analytics' ), + 'plural' => __( 'Audiences', 'altis-analytics' ), + ] + ); +} + +/** + * Set up default event data mappings. + */ +function register_default_event_data_maps() { + register_field( 'attributes.referer', __( 'Referrer', 'altis-analytics' ) ); + register_field( 'endpoint.Demographic.Model', __( 'Browser', 'altis-analytics' ) ); + register_field( 'endpoint.Demographic.ModelVersion', __( 'Browser version', 'altis-analytics' ) ); + register_field( 'endpoint.Demographic.Locale', __( 'Browser Locale', 'altis-analytics' ) ); + register_field( 'endpoint.Demographic.Platform', __( 'Operating system', 'altis-analytics' ) ); + register_field( 'endpoint.Demographic.PlatformVersion', __( 'Operating system version', 'altis-analytics' ) ); + register_field( 'endpoint.Location.Country', __( 'Country', 'altis-analytics' ) ); +} + +/** + * Remove built-in metaboxes from the Audiences edit page. + */ +function adjust_meta_boxes() { + remove_meta_box( 'submitdiv', POST_TYPE, 'side' ); + remove_meta_box( 'slugdiv', POST_TYPE, 'normal' ); + remove_meta_box( 'postexcerpt', POST_TYPE, 'normal' ); +} + +/** + * Temporarily hide the title field. + * + * Removes post type support on the edit screen temporarily, then readds as + * soon as the UI no longer cares. + */ +function hide_title_field( WP_Post $post ) { + if ( $post->post_type !== POST_TYPE ) { + return; + } + + remove_post_type_support( POST_TYPE, 'title' ); + + $callback = function () use ( &$callback ) { + add_post_type_support( POST_TYPE, 'title' ); + remove_action( 'edit_form_after_title', $callback ); + }; + add_action( 'edit_form_after_title', $callback ); +} + +/** + * Render the "Status" column for an audience. + */ +function render_status_column() : void { + if ( get_post_status() === 'publish' ) { + esc_html_e( 'Active', 'altis-analytics' ); + } else { + esc_html_e( 'Inactive', 'altis-analytics' ); + } +} + +/** + * Add Audience UI placeholder. + * + * @param WP_Post $post + */ +function audience_ui( WP_Post $post ) { + if ( $post->post_type !== POST_TYPE ) { + return; + } + + printf( + '
' . + '

%s

' . + '' . + '
', + $post->ID, + esc_attr( wp_json_encode( get_audience( $post->ID ) ) ), + esc_attr( wp_json_encode( get_field_data() ) ), + esc_html__( 'Loading...', 'altis-analytics' ), + esc_html__( 'Javascript is required to use the audience editor.', 'altis-analytics' ) + ); + + wp_nonce_field( 'altis-analytics', 'altis_analytics_nonce' ); +} + +/** + * Add estimate UI placeholder. + * + * @param WP_Post $post + */ +function estimate_ui( WP_Post $post = null ) { + // Use current post if none passed. + if ( ! $post ) { + $post = get_post(); + } + + if ( $post->post_type !== POST_TYPE ) { + return; + } + + $audience = get_audience( $post->ID ); + + printf( + '
' . + '

%s

' . + '' . + '
', + esc_attr( wp_json_encode( $audience ) ), + esc_html__( 'Loading...', 'altis-analytics' ), + // translators: %d is the number of visitors matching the audience + sprintf( esc_html__( '%d visitors in the last 7 days', 'altis-analytics' ), $audience['count'] ?? 0 ) + ); +} + +/** + * Support saving the audience configuration the old school way. + * + * @param int $post_id The current audience post ID. + */ +function save_post( $post_id ) { + if ( ! isset( $_POST['altis_analytics_nonce'] ) ) { + return; + } + + if ( ! wp_verify_nonce( $_POST['altis_analytics_nonce'], 'altis-analytics' ) ) { + return; + } + + if ( ! isset( $_POST['audience'] ) ) { + return; + } + + if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { + return; + } + + if ( ! is_array( $_POST['audience'] ) ) { + return; + } + + save_audience( $post_id, $_POST['audience'] ); +} + +/** + * Saves an audience config to the given post ID. + * + * @param integer $post_id The post ID to save the audience to. + * @param array $audience The audience configuration. + * @return bool True on successful update or false on failure. + */ +function save_audience( int $post_id, array $audience ) : bool { + // Clear errors. + delete_post_meta( $post_id, 'audience_error' ); + + // Validate using audience schema. + $valid = rest_validate_value_from_schema( $audience, REST_API\get_audience_schema(), 'audience' ); + + if ( is_wp_error( $valid ) ) { + update_post_meta( $post_id, 'audience_error', wp_slash( $valid->get_error_message() ) ); + return false; + } + + // Save the audience configuration. + return (bool) update_post_meta( $post_id, 'audience', wp_slash( $audience ) ); +} + +/** + * Remove quick edit inline action. + * + * @param array $actions Inline actions array. + * @param WP_Post $post The current post. + * @return array + */ +function remove_quick_edit( array $actions, WP_Post $post ) : array { + if ( $post->post_type !== POST_TYPE ) { + return $actions; + } + + unset( $actions['inline hide-if-no-js'] ); + return $actions; +} + +/** + * Removes the quick edit bulk action. + * + * @param array $actions Bulk actions array. + * @return array + */ +function remove_bulk_actions( array $actions ) : array { + unset( $actions['edit'] ); + return $actions; +} + +/** + * Get the audience configuration data. + * + * @param int $post_id + * @return array|null + */ +function get_audience( int $post_id ) : ?array { + return wp_unslash( get_post_meta( $post_id, 'audience', true ) ?: null ); +} + +/** + * Get list of mapped analytics data fields. + * + * @return array + */ +function get_fields() : array { + global $altis_analytics_event_data_maps; + return array_values( $altis_analytics_event_data_maps ?: [] ); +} + +/** + * Adds an elasticsearch to field data mapping. + * + * @param string $field The elasticsearch field name. + * @param string $label A human readable label for the field. + */ +function register_field( string $field, string $label ) { + global $altis_analytics_event_data_maps; + if ( empty( $altis_analytics_event_data_maps ) ) { + $altis_analytics_event_data_maps = []; + } + + $altis_analytics_event_data_maps[ $field ] = [ + 'name' => $field, + 'label' => $label, + 'type' => Utils\get_field_type( $field ), + ]; + ksort( $altis_analytics_event_data_maps ); +} + +/** + * Queue up the audience admin UI scripts. + */ +function admin_enqueue_scripts() { + if ( get_current_screen()->post_type !== POST_TYPE ) { + return; + } + + wp_dequeue_script( 'post' ); + + wp_enqueue_script( + 'altis-analytics-audience-ui', + Utils\get_asset_url( 'audiences.js' ), + [ + 'lodash', + 'react', + 'react-dom', + 'wp-i18n', + 'wp-hooks', + 'wp-data', + 'wp-components', + 'wp-api-fetch', + ], + null, + true + ); + + wp_enqueue_style( 'wp-components' ); + + // Hydrate with data to speed up the front end. + $data = [ + 'Fields' => get_field_data(), + ]; + + // Add post data server side to load front end quickly on legacy edit screens. + if ( isset( $_GET['post'] ) ) { + $response = rest_do_request( sprintf( '/wp/v2/audiences/%d', $_GET['post'] ) ); + $data['Current'] = $response->get_data(); + } + + wp_add_inline_script( + 'altis-analytics-audience-ui', + sprintf( + 'window.Altis = window.Altis || {};' . + 'window.Altis.Analytics = window.Altis.Analytics || {};' . + 'window.Altis.Analytics.BuildURL = %s;' . + 'window.Altis.Analytics.Audiences = %s;', + wp_json_encode( plugins_url( 'build', dirname( __FILE__, 2 ) ) ), + wp_json_encode( (object) $data ) + ), + 'before' + ); + + wp_enqueue_style( + 'altis-analytics-audience-ui', + plugins_url( 'src/audiences/index.css', dirname( __FILE__, 2 ) ), + [], + '2020-03-19-1' + ); +} + +/** + * Get estimated audience size. + * + * @param array $audience + * @return void + */ +function get_estimate( array $audience ) : ?array { + $query = [ + 'query' => [ + 'bool' => [ + 'filter' => [ + // Set current site. + [ + 'term' => [ + 'attributes.blogId.keyword' => get_current_blog_id(), + ], + ], + + // Last 7 days. + [ + 'range' => [ + 'event_timestamp' => [ + 'gte' => Utils\milliseconds() - ( WEEK_IN_SECONDS * 1000 ), + ], + ], + ], + + // Limit event type to pageView. + [ + 'term' => [ + 'event_type.keyword' => 'pageView', + ], + ], + ], + ], + ], + 'aggs' => [ + 'estimate' => [ + 'cardinality' => [ + 'field' => 'endpoint.Id.keyword', + ], + ], + 'histogram' => [ + 'histogram' => [ + 'field' => 'event_timestamp', + 'interval' => 6 * HOUR_IN_SECONDS * 1000, // 6 hour chunks. + 'extended_bounds' => [ + 'min' => Utils\milliseconds() - ( WEEK_IN_SECONDS * 1000 ), + 'max' => Utils\milliseconds(), + ], + ], + ], + ], + 'size' => 0, + 'sort' => [ + 'event_timestamp' => 'desc', + ], + ]; + + // Append the groups query. + $query['query']['bool']['filter'][] = build_audience_query( $audience ); + + $key = sprintf( 'estimate:%s', sha1( serialize( $audience ) ) ); + $cache = wp_cache_get( $key, 'altis-audiences' ); + if ( $cache ) { + return $cache; + } + + $result = Utils\query( $query ); + + if ( ! $result ) { + return $result; + } + + $histogram = array_map( function ( array $bucket ) { + return [ + 'index' => $bucket['key'], + 'count' => $bucket['doc_count'], + ]; + }, $result['aggregations']['histogram']['buckets'] ); + + $estimate = [ + 'count' => $result['aggregations']['estimate']['value'], + 'total' => get_unique_endpoint_count(), + 'histogram' => array_values( $histogram ), + ]; + + wp_cache_set( $key, $estimate, 'altis-audiences', HOUR_IN_SECONDS ); + + return $estimate; +} + +/** + * Get total unique endpoints for the past 7 days. + * + * @return integer|null + */ +function get_unique_endpoint_count() : ?int { + $query = [ + 'query' => [ + 'bool' => [ + 'filter' => [ + // Set current site. + [ + 'term' => [ + 'attributes.blogId.keyword' => get_current_blog_id(), + ], + ], + + // Last 7 days. + [ + 'range' => [ + 'event_timestamp' => [ + 'gte' => Utils\milliseconds() - ( WEEK_IN_SECONDS * 1000 ), + ], + ], + ], + + // Limit event type to pageView. + [ + 'term' => [ + 'event_type.keyword' => 'pageView', + ], + ], + ], + ], + ], + 'aggs' => [ + 'count' => [ + 'cardinality' => [ + 'field' => 'endpoint.Id.keyword', + ], + ], + ], + 'size' => 0, + 'sort' => [ + 'event_timestamp' => 'desc', + ], + ]; + + $cache = wp_cache_get( 'total-uniques', 'altis-audiences' ); + if ( $cache ) { + return $cache; + } + + $result = Utils\query( $query ); + + if ( ! $result ) { + return $result; + } + + $count = intval( $result['aggregations']['count']['value'] ); + + wp_cache_set( 'total-uniques', $count, 'altis-audiences', HOUR_IN_SECONDS ); + + return $count; +} + +/** + * Get a list of mapped fields and some information on the available + * data for those fields. + * + * Example results: + * + * [ + * [ + * 'name' => 'endpoint.Location.Country', + * 'label' => 'Country', + * 'type' => 'string', + * 'data' => [ + * [ 'key' => 'GB', 'doc_count' => 281 ], + * [ 'key' => 'US', 'doc_count' => 127 ] + * ] + * ], + * [ + * 'name' => 'metrics.UserSpend', + * 'label' => 'Total User Spend', + * 'type' => 'number', + * 'stats' => [ + * 'sum' => 560, + * 'min' => 10, + * 'max' => 210, + * 'avg' => 35 + * ] + * ] + * ] + * + * @return array|null + */ +function get_field_data() : ?array { + $maps = get_fields(); + + $key = sprintf( 'fields:%s', sha1( serialize( wp_list_pluck( $maps, 'name' ) ) ) ); + $cache = wp_cache_get( $key, 'altis-audiences' ); + if ( $cache ) { + return $cache; + } + + $query = [ + 'query' => [ + 'bool' => [ + 'filter' => [ + // Query for current site. + [ + 'term' => [ + 'attributes.blogId.keyword' => (string) get_current_blog_id(), + ], + ], + + // Last 7 days. + [ + 'range' => [ + 'event_timestamp' => [ + 'gte' => Utils\milliseconds() - ( WEEK_IN_SECONDS * 1000 ), + ], + ], + ], + ], + ], + ], + 'size' => 0, + 'aggs' => [], + 'sort' => [ + 'event_timestamp' => 'desc', + ], + ]; + + foreach ( $maps as $map ) { + // For numeric fields get a simple stats aggregation. + if ( Utils\get_field_type( $map['name'] ) === 'number' ) { + $query['aggs'][ $map['name'] ] = [ + 'stats' => [ + 'field' => $map['name'], + ], + ]; + } + // Default to terms aggregations for top 100 different values available for each field. + if ( Utils\get_field_type( $map['name'] ) === 'string' ) { + $query['aggs'][ $map['name'] ] = [ + 'terms' => [ + 'field' => "{$map['name']}.keyword", + 'size' => 100, + ], + ]; + } + } + + $result = Utils\query( $query ); + + if ( ! $result ) { + return $result; + } + + $aggregations = $result['aggregations']; + + // Normalise aggregations to useful just the useful data. + $fields = []; + foreach ( $maps as $field ) { + $field_name = $field['name']; + if ( isset( $aggregations[ $field_name ]['buckets'] ) ) { + $field['data'] = array_map( function ( $bucket ) { + return [ + 'value' => $bucket['key'], + 'count' => $bucket['doc_count'], + ]; + }, $aggregations[ $field_name ]['buckets'] ); + } else { + $field['stats'] = $aggregations[ $field_name ]; + } + + $fields[] = $field; + } + + // Cache the data. + wp_cache_set( $key, $fields, 'altis-audiences', HOUR_IN_SECONDS ); + + return $fields; +} + +/** + * Convert an audience config array to an Elasticsearch query. + * The response is designed to be used within a bool filter query eg: + * + * $query = [ + * 'query' => [ + * 'bool' => [ + * 'filter' => [ + * get_filter_query( $audience ), + * ], + * ], + * ], + * ]; + * + * @param array $audience + * @return array + */ +function build_audience_query( array $audience ) : array { + // Map the include values to elasticsearch query filters. + $include_map = [ + 'any' => 'should', + 'all' => 'filter', + 'none' => 'must_not', + ]; + + $group_queries = []; + + foreach ( $audience['groups'] as $group ) { + $group_include = $include_map[ $group['include'] ]; + $group_query = [ + 'bool' => [ + $group_include => [], + ], + ]; + + foreach ( $group['rules'] as $rule ) { + $rule_query = [ + 'bool' => [ + 'filter' => [], + 'must_not' => [], + ], + ]; + + // Handle string comparisons. + if ( Utils\get_field_type( $rule['field'] ) === 'string' ) { + switch ( $rule['operator'] ) { + case '=': + $rule_query['bool']['filter'][] = [ + 'term' => [ + "{$rule['field']}.keyword" => $rule['value'], + ], + ]; + break; + case '!=': + $rule_query['bool']['must_not'][] = [ + 'term' => [ + "{$rule['field']}.keyword" => $rule['value'], + ], + ]; + break; + case '*=': + $rule_query['bool']['filter'][] = [ + 'wildcard' => [ + "{$rule['field']}.keyword" => "*{$rule['value']}*", + ], + ]; + break; + case '!*': + $rule_query['bool']['must_not'][] = [ + 'wildcard' => [ + "{$rule['field']}.keyword" => "*{$rule['value']}*", + ], + ]; + break; + case '^=': + $rule_query['bool']['filter'][] = [ + 'wildcard' => [ + "{$rule['field']}.keyword" => "{$rule['value']}*", + ], + ]; + break; + } + } + + // Handle numeric field comparisons. + if ( Utils\get_field_type( $rule['field'] ) === 'number' ) { + $rule_query['bool']['filter'][] = [ + 'range' => [ $rule['field'] => [ $rule['operator'] => intval( $rule['value'] ) ] ], + ]; + } + + // Add the rule query to the group. + $group_query['bool'][ $group_include ][] = $rule_query; + } + + // Add the group query to the list of group queries. + $group_queries[] = $group_query; + } + + $groups_include = $include_map[ $audience['include'] ]; + $groups_query = [ + 'bool' => [ + $groups_include => $group_queries, + ], + ]; + + return $groups_query; +} diff --git a/inc/audiences/rest_api/namespace.php b/inc/audiences/rest_api/namespace.php new file mode 100644 index 00000000..bf814998 --- /dev/null +++ b/inc/audiences/rest_api/namespace.php @@ -0,0 +1,267 @@ + WP_REST_Server::READABLE, + 'callback' => 'Altis\\Analytics\\Audiences\\get_field_data', + 'permission_callback' => __NAMESPACE__ . '\\check_edit_permission', + ], + 'schema' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'required' => true, + ], + 'label' => [ + 'type' => 'string', + 'required' => true, + ], + 'type' => [ + 'type' => 'string', + 'required' => true, + ], + 'data' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'value' => [ 'type' => 'string' ], + 'count' => [ 'type' => 'number' ], + ], + ], + ], + 'stats' => [ + 'type' => 'object', + 'properties' => [ + 'count' => [ 'type' => 'number' ], + 'min' => [ 'type' => 'number' ], + 'max' => [ 'type' => 'number' ], + 'avg' => [ 'type' => 'number' ], + 'sum' => [ 'type' => 'number' ], + ], + ], + ], + ], + ], + ] ); + + // Fetch an audience size estimate. + register_rest_route( 'analytics/v1', 'audiences/estimate', [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => __NAMESPACE__ . '\\handle_estimate_request', + 'permission_callback' => __NAMESPACE__ . '\\check_edit_permission', + 'args' => [ + 'audience' => [ + 'description' => __( 'A URL encoded audience configuration JSON string', 'altis-analytics' ), + 'required' => true, + 'type' => 'string', + 'validate_callback' => __NAMESPACE__ . '\\validate_estimate_audience', + 'sanitize_callback' => __NAMESPACE__ . '\\sanitize_estimate_audience', + ], + ], + ], + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'count' => [ 'type' => 'number' ], + 'total' => [ 'type' => 'number' ], + 'histogram' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'index' => [ 'type' => 'string' ], + 'count' => [ 'type' => 'number' ], + ], + ], + ], + ], + ], + ] ); + + // Handle the audience configuration data retrieval and saving via the REST API. + register_rest_field( Audiences\POST_TYPE, 'audience', [ + 'get_callback' => function ( array $post ) { + return Audiences\get_audience( $post['id'] ); + }, + 'update_callback' => function ( $value, WP_Post $post ) { + return Audiences\save_audience( $post->ID, (array) $value ); + }, + 'schema' => get_audience_schema(), + ] ); +} + +/** + * Get the JSON schema for the rule object. + * + * @return array + */ +function get_rule_schema() : array { + return [ + 'type' => 'object', + 'properties' => [ + 'field' => [ + 'type' => 'string', + ], + 'operator' => [ + 'type' => 'string', + 'enum' => Audiences\COMPARISON_OPERATORS, + ], + 'value' => [ + 'type' => [ + 'string', + 'number', + ], + ], + ], + ]; +} + +/** + * Get the JSON schema for the group object. + * + * @return array + */ +function get_group_schema() : array { + return [ + 'type' => 'object', + 'properties' => [ + 'include' => [ + 'type' => 'string', + 'enum' => [ + 'any', + 'all', + 'none', + ], + ], + 'rules' => [ + 'type' => 'array', + 'items' => get_rule_schema(), + ], + ], + ]; +} + +/** + * Get the JSON schema for the audience configuration object. + * + * @return array + */ +function get_audience_schema() : array { + return [ + 'type' => 'object', + 'properties' => [ + 'include' => [ + 'type' => 'string', + 'enum' => [ + 'any', + 'all', + 'none', + ], + ], + 'groups' => [ + 'type' => 'array', + 'items' => get_group_schema(), + ], + ], + ]; +} + +/** + * Retrieve the estimate response. + * + * @param WP_REST_Request $request + * @return WP_REST_Response + */ +function handle_estimate_request( WP_REST_Request $request ) : WP_REST_Response { + $audience = $request->get_param( 'audience' ); + $estimate = Audiences\get_estimate( $audience ); + return rest_ensure_response( $estimate ); +} + +/** + * Validate the estimate audience configuration parameter. + * + * @param string $param The audience configuration JSON string. + * @return bool + */ +function validate_estimate_audience( $param ) { + $audience = json_decode( $param, true ); + + if ( json_last_error() !== JSON_ERROR_NONE ) { + return new WP_Error( + 'altis_audience_estimate_json_invalid', + sprintf( + /* translators: %s: JSON error message */ + __( 'Could not decode JSON: %s', 'altis-analytics' ), + json_last_error_msg() + ) + ); + } + + // Validate against the audience schema after decoding. + return rest_validate_value_from_schema( $audience, get_audience_schema(), 'audience' ); +} + +/** + * Sanitize the estimate audience value. + * + * @param string $param The audience configuration JSON string. + * @return array|WP_Error + */ +function sanitize_estimate_audience( $param ) { + $audience = json_decode( $param, true ); + + if ( json_last_error() !== JSON_ERROR_NONE ) { + return new WP_Error( + 'altis_audience_estimate_json_invalid', + sprintf( + /* translators: %s: JSON error message */ + __( 'Could not decode JSON: %s', 'altis-analytics' ), + json_last_error_msg() + ) + ); + } + + return $audience; +} + +/** + * Check user can edit audience posts. + * + * @return bool + */ +function check_edit_permission() : bool { + $type = get_post_type_object( Audiences\POST_TYPE ); + return current_user_can( $type->cap->edit_posts ); +} diff --git a/inc/namespace.php b/inc/namespace.php index 018e616e..0aae0e98 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -3,12 +3,15 @@ * Altis Analytics. * * @package altis-analytics - * */ namespace Altis\Analytics; +use function Altis\Analytics\Utils\get_asset_url; + function setup() { + // Setup audiences. + Audiences\setup(); // Handle async scripts. add_filter( 'script_loader_tag', __NAMESPACE__ . '\\async_scripts', 20, 2 ); // Load analytics scripts super early. @@ -157,7 +160,7 @@ function async_scripts( string $tag, string $handle ) : string { function enqueue_scripts() { global $wp_scripts; - wp_enqueue_script( 'altis-analytics', plugins_url( 'build/analytics.js', __DIR__ ), [], '__SCRIPT_HASH__', false ); + wp_enqueue_script( 'altis-analytics', get_asset_url( 'analytics.js' ), [], null, false ); wp_add_inline_script( 'altis-analytics', sprintf( diff --git a/inc/utils/namespace.php b/inc/utils/namespace.php index 936a96a0..bc26dd9d 100644 --- a/inc/utils/namespace.php +++ b/inc/utils/namespace.php @@ -5,6 +5,38 @@ namespace Altis\Analytics\Utils; +use const Altis\Analytics\ROOT_DIR; + +/** + * Return asset file name based on generated manifest.json file. + * + * @param string $filename + * @return string|false + */ +function get_asset_url( string $filename ) { + $manifest_file = ROOT_DIR . '/build/manifest.json'; + + if ( ! file_exists( $manifest_file ) ) { + return false; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $manifest = file_get_contents( $manifest_file ); + $manifest = json_decode( $manifest, true ); + + if ( ! $manifest || ! isset( $manifest[ $filename ] ) ) { + return false; + } + + $path = $manifest[ $filename ]; + + if ( strpos( $path, 'http' ) !== false ) { + return $path; + } + + return plugins_url( $manifest[ $filename ], ROOT_DIR . '/build/assets' ); +} + /** * Calculate the combined standard deviation for multiple groups of * averages, standard deviations and sizes. @@ -81,12 +113,19 @@ function get_elasticsearch_url() : string { * * @param array $query A full elasticsearch Query DSL array. * @param array $params URL query parameters to append to request URL. + * @param string $path The endpoint to query against, defaults to _search. * @return array|null */ -function query( array $query, array $params = [] ) : ?array { +function query( array $query, array $params = [], string $path = '_search' ) : ?array { + + // Sanitize path. + $path = trim( $path, '/' ); // Get URL. - $url = add_query_arg( $params, get_elasticsearch_url() . '/analytics*/_search' ); + $url = add_query_arg( $params, get_elasticsearch_url() . '/analytics*/' . $path ); + + // Escape the URL to ensure nothing strange was passed in via $path. + $url = esc_url_raw( $url ); $response = wp_remote_post( $url, [ 'headers' => [ @@ -97,16 +136,24 @@ function query( array $query, array $params = [] ) : ?array { if ( wp_remote_retrieve_response_code( $response ) !== 200 || is_wp_error( $response ) ) { if ( is_wp_error( $response ) ) { - trigger_error( sprintf( - 'Analytics: elasticsearch query failed: %s', - $response->get_error_message() - ), E_USER_WARNING ); + trigger_error( + sprintf( + "Analytics: elasticsearch query failed:\n%s\n%s", + $url, + $response->get_error_message() + ), + E_USER_WARNING + ); } else { - trigger_error( sprintf( - "Analytics: elasticsearch query failed:\n%s\n%s", - json_encode( $query ), - wp_remote_retrieve_body( $response ) - ), E_USER_WARNING ); + trigger_error( + sprintf( + "Analytics: elasticsearch query failed:\n%s\n%s\n%s", + $url, + json_encode( $query ), + wp_remote_retrieve_body( $response ) + ), + E_USER_WARNING + ); } return null; } @@ -119,6 +166,18 @@ function query( array $query, array $params = [] ) : ?array { return null; } + // Enable logging for analytics queries. + if ( defined( 'ALTIS_ANALYTICS_LOG_QUERIES' ) && ALTIS_ANALYTICS_LOG_QUERIES ) { + error_log( + sprintf( + "Analytics: elasticsearch query:\n%s\n%s\n%s", + $url, + json_encode( $query ), + wp_remote_retrieve_body( $response ) + ) + ); + } + return $result; } @@ -223,3 +282,40 @@ function merge_aggregates( array $current, array $new, string $bucket_type = '' return $merged; } + +/** + * Determine type of Elasticsearch field by name. + * + * @param string $field The full field name. + * @return string|null $type One of 'string', 'number' or 'date'. + */ +function get_field_type( string $field ) : ?string { + if ( empty( $field ) ) { + return null; + } + + $numeric_fields = [ + 'event_timestamp', + 'arrival_timestamp', + 'session.start_timestamp', + 'session.stop_timestamp', + ]; + + $is_numeric_field = in_array( $field, $numeric_fields, true ); + $is_metric = stripos( $field, 'metrics' ) !== false; + + if ( $is_numeric_field || $is_metric ) { + return 'number'; + } + + $date_fields = [ + 'endpoint.CreationDate', + 'endpoint.EffectiveDate', + ]; + + if ( in_array( $field, $date_fields, true ) ) { + return 'date'; + } + + return 'string'; +} diff --git a/package-lock.json b/package-lock.json index 443ee806..0a31c98f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -571,6 +571,95 @@ "semver": "^5.5.0" } }, + "@babel/helper-create-class-features-plugin": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.8.6.tgz", + "integrity": "sha512-klTBDdsr+VFFqaDHm5rR69OpEQtO2Qv8ECxHS1mNhJJvaHArR6a1xTf5K/eZW7eZpJbhCx3NW1Yt/sKsLXLblg==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.8.3", + "@babel/helper-member-expression-to-functions": "^7.8.3", + "@babel/helper-optimise-call-expression": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/helper-replace-supers": "^7.8.6", + "@babel/helper-split-export-declaration": "^7.8.3" + }, + "dependencies": { + "@babel/generator": { + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.9.4.tgz", + "integrity": "sha512-rjP8ahaDy/ouhrvCoU1E5mqaitWrxwuNGU+dy1EpaoK48jZay4MdkskKGIMHLZNewg8sAsqpGSREJwP0zH3YQA==", + "dev": true, + "requires": { + "@babel/types": "^7.9.0", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-replace-supers": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.8.6.tgz", + "integrity": "sha512-PeMArdA4Sv/Wf4zXwBKPqVj7n9UF/xg6slNRtZW84FM7JpE1CbG8B612FyM4cxrf4fMAMGO0kR7voy1ForHHFA==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.8.3", + "@babel/helper-optimise-call-expression": "^7.8.3", + "@babel/traverse": "^7.8.6", + "@babel/types": "^7.8.6" + } + }, + "@babel/parser": { + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.4.tgz", + "integrity": "sha512-bC49otXX6N0/VYhgOMh4gnP26E9xnDZK3TmbNpxYzzz9BQLBosQwfyOe9/cXUU3txYhTzLCbcqd5c8y/OmCjHA==", + "dev": true + }, + "@babel/traverse": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.9.0.tgz", + "integrity": "sha512-jAZQj0+kn4WTHO5dUZkZKhbFrqZE7K5LAQ5JysMnmvGij+wOdr+8lWqPeW0BcF4wFwrEXXtdGO7wcV6YPJcf3w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/generator": "^7.9.0", + "@babel/helper-function-name": "^7.8.3", + "@babel/helper-split-export-declaration": "^7.8.3", + "@babel/parser": "^7.9.0", + "@babel/types": "^7.9.0", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, "@babel/helper-create-regexp-features-plugin": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.8.3.tgz", @@ -715,6 +804,12 @@ "@babel/types": "^7.8.3" } }, + "@babel/helper-validator-identifier": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.0.tgz", + "integrity": "sha512-6G8bQKjOh+of4PV/ThDm/rRqlU7+IGoJuofpagU5GlEl29Vv0RGqqt86ZGRV8ZuSOY3o+8yXl5y782SMcG7SHw==", + "dev": true + }, "@babel/helper-wrap-function": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.8.3.tgz", @@ -761,6 +856,16 @@ "@babel/plugin-syntax-async-generators": "^7.8.0" } }, + "@babel/plugin-proposal-class-properties": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.8.3.tgz", + "integrity": "sha512-EqFhbo7IosdgPgZggHaNObkmO1kNUe3slaKu54d5OWvy+p9QIKOzK1GAEpAIsZtWVtPXUHSMcT4smvDrCfY4AA==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3" + } + }, "@babel/plugin-proposal-dynamic-import": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.8.3.tgz", @@ -1381,6 +1486,192 @@ "to-fast-properties": "^2.0.0" } }, + "@emotion/cache": { + "version": "10.0.29", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz", + "integrity": "sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ==", + "requires": { + "@emotion/sheet": "0.9.4", + "@emotion/stylis": "0.8.5", + "@emotion/utils": "0.11.3", + "@emotion/weak-memoize": "0.2.5" + } + }, + "@emotion/core": { + "version": "10.0.28", + "resolved": "https://registry.npmjs.org/@emotion/core/-/core-10.0.28.tgz", + "integrity": "sha512-pH8UueKYO5jgg0Iq+AmCLxBsvuGtvlmiDCOuv8fGNYn3cowFpLN98L8zO56U0H1PjDIyAlXymgL3Wu7u7v6hbA==", + "requires": { + "@babel/runtime": "^7.5.5", + "@emotion/cache": "^10.0.27", + "@emotion/css": "^10.0.27", + "@emotion/serialize": "^0.11.15", + "@emotion/sheet": "0.9.4", + "@emotion/utils": "0.11.3" + } + }, + "@emotion/css": { + "version": "10.0.27", + "resolved": "https://registry.npmjs.org/@emotion/css/-/css-10.0.27.tgz", + "integrity": "sha512-6wZjsvYeBhyZQYNrGoR5yPMYbMBNEnanDrqmsqS1mzDm1cOTu12shvl2j4QHNS36UaTE0USIJawCH9C8oW34Zw==", + "requires": { + "@emotion/serialize": "^0.11.15", + "@emotion/utils": "0.11.3", + "babel-plugin-emotion": "^10.0.27" + } + }, + "@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==" + }, + "@emotion/is-prop-valid": { + "version": "0.8.7", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.7.tgz", + "integrity": "sha512-OPkKzUeiid0vEKjZqnGcy2mzxjIlCffin+L2C02pdz/bVlt5zZZE2VzO0D3XOPnH0NEeF21QNKSXiZphjr4xiQ==", + "requires": { + "@emotion/memoize": "0.7.4" + } + }, + "@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==" + }, + "@emotion/serialize": { + "version": "0.11.16", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.11.16.tgz", + "integrity": "sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==", + "requires": { + "@emotion/hash": "0.8.0", + "@emotion/memoize": "0.7.4", + "@emotion/unitless": "0.7.5", + "@emotion/utils": "0.11.3", + "csstype": "^2.5.7" + } + }, + "@emotion/sheet": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-0.9.4.tgz", + "integrity": "sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA==" + }, + "@emotion/stylis": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz", + "integrity": "sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==" + }, + "@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" + }, + "@emotion/utils": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz", + "integrity": "sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==" + }, + "@emotion/weak-memoize": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz", + "integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==" + }, + "@types/anymatch": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz", + "integrity": "sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA==" + }, + "@types/events": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz", + "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==" + }, + "@types/glob": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.1.tgz", + "integrity": "sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w==", + "requires": { + "@types/events": "*", + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "@types/minimatch": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", + "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==" + }, + "@types/node": { + "version": "13.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.9.2.tgz", + "integrity": "sha512-bnoqK579sAYrQbp73wwglccjJ4sfRdKU7WNEZ5FW4K2U6Kc0/eZ5kvXG0JKsEKFB50zrFmfFt52/cvBbZa7eXg==" + }, + "@types/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" + }, + "@types/source-list-map": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz", + "integrity": "sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==" + }, + "@types/tapable": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.5.tgz", + "integrity": "sha512-/gG2M/Imw7cQFp8PGvz/SwocNrmKFjFsm5Pb8HdbHkZ1K8pmuPzOX4VeVoiEecFCVf4CsN1r3/BRvx+6sNqwtQ==" + }, + "@types/uglify-js": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.0.4.tgz", + "integrity": "sha512-SudIN9TRJ+v8g5pTG8RRCqfqTMNqgWCKKd3vtynhGzkIIjxaicNAMuY5TRadJ6tzDu3Dotf3ngaMILtmOdmWEQ==", + "requires": { + "source-map": "^0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + } + } + }, + "@types/webpack": { + "version": "4.41.7", + "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.7.tgz", + "integrity": "sha512-OQG9viYwO0V1NaNV7d0n79V+n6mjOV30CwgFPIfTzwmk8DHbt+C4f2aBGdCYbo3yFyYD6sjXfqqOjwkl1j+ulA==", + "requires": { + "@types/anymatch": "*", + "@types/node": "*", + "@types/tapable": "*", + "@types/uglify-js": "*", + "@types/webpack-sources": "*", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + } + } + }, + "@types/webpack-sources": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-0.1.6.tgz", + "integrity": "sha512-FtAWR7wR5ocJ9+nP137DV81tveD/ZgB1sadnJ/axUGM3BUVfRPx8oQNMtv3JNfTeHx3VP7cXiyfR/jmtEsVHsQ==", + "requires": { + "@types/node": "*", + "@types/source-list-map": "*", + "source-map": "^0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + } + } + }, "@webassemblyjs/ast": { "version": "1.8.5", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.8.5.tgz", @@ -1758,6 +2049,19 @@ "is-string": "^1.0.5" } }, + "array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=", + "requires": { + "array-uniq": "^1.0.1" + } + }, + "array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=" + }, "array-unique": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", @@ -1878,6 +2182,49 @@ "object.assign": "^4.1.0" } }, + "babel-plugin-emotion": { + "version": "10.0.29", + "resolved": "https://registry.npmjs.org/babel-plugin-emotion/-/babel-plugin-emotion-10.0.29.tgz", + "integrity": "sha512-7Jpi1OCxjyz0k163lKtqP+LHMg5z3S6A7vMBfHnF06l2unmtsOmFDzZBpGf0CWo1G4m8UACfVcDJiSiRuu/cSw==", + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "@emotion/hash": "0.8.0", + "@emotion/memoize": "0.7.4", + "@emotion/serialize": "^0.11.16", + "babel-plugin-macros": "^2.0.0", + "babel-plugin-syntax-jsx": "^6.18.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^1.0.5", + "find-root": "^1.1.0", + "source-map": "^0.5.7" + } + }, + "babel-plugin-macros": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz", + "integrity": "sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==", + "requires": { + "@babel/runtime": "^7.7.2", + "cosmiconfig": "^6.0.0", + "resolve": "^1.12.0" + } + }, + "babel-plugin-styled-components": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-1.10.7.tgz", + "integrity": "sha512-MBMHGcIA22996n9hZRf/UJLVVgkEOITuR2SvjHLb5dSTUyR4ZRGn+ngITapes36FI3WLxZHfRhkA1ffHxihOrg==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.0.0", + "@babel/helper-module-imports": "^7.0.0", + "babel-plugin-syntax-jsx": "^6.18.0", + "lodash": "^4.17.11" + } + }, + "babel-plugin-syntax-jsx": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz", + "integrity": "sha1-CvMqmm4Tyno/1QaeYtew9Y0NiUY=" + }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -2182,14 +2529,18 @@ "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" }, "camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" }, + "camelize": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.0.tgz", + "integrity": "sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=" + }, "caniuse-lite": { "version": "1.0.30001027", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001027.tgz", @@ -2278,6 +2629,15 @@ } } }, + "clean-webpack-plugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clean-webpack-plugin/-/clean-webpack-plugin-3.0.0.tgz", + "integrity": "sha512-MciirUH5r+cYLGCOL5JX/ZLzOZbVr1ot3Fw+KcvbhUb6PM+yycqd9ZhIlcigQ5gl+XhppNmw3bEFuaaMNyLj3A==", + "requires": { + "@types/webpack": "^4.4.31", + "del": "^4.1.1" + } + }, "cli-cursor": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", @@ -2481,6 +2841,36 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, + "cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + }, + "dependencies": { + "parse-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.0.0.tgz", + "integrity": "sha512-OOY5b7PAEFV0E2Fir1KOkxchnZNCdowAJgQ5NuxjpBKTRP3pQhwkrkxqQjeoKJ+fO7bCpmIZaogI4eZGDMEGOw==", + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1", + "lines-and-columns": "^1.1.6" + } + }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" + } + } + }, "create-ecdh": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz", @@ -2545,6 +2935,26 @@ "randomfill": "^1.0.3" } }, + "css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha1-/qJhbcZ2spYmhrOvjb2+GAskTgU=" + }, + "css-to-react-native": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.0.0.tgz", + "integrity": "sha512-Ro1yETZA813eoyUp2GDBhG2j+YggidUmzO1/v9eYBKR2EHVEniE2MI/NqpTQ954BMpTPZFsGNPm46qFB9dpaPQ==", + "requires": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, + "csstype": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.9.tgz", + "integrity": "sha512-xz39Sb4+OaTsULgUERcCk+TJj8ylkL4aSVDQiX/ksxbELSqwkgt4d4RD7fovIdgJGSuNYqwZEiVjYY5l0ask+Q==" + }, "cyclist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz", @@ -2630,6 +3040,20 @@ } } }, + "del": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/del/-/del-4.1.1.tgz", + "integrity": "sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==", + "requires": { + "@types/glob": "^7.1.1", + "globby": "^6.1.0", + "is-path-cwd": "^2.0.0", + "is-path-in-cwd": "^2.0.0", + "p-map": "^2.0.0", + "pify": "^4.0.1", + "rimraf": "^2.6.3" + } + }, "depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", @@ -2673,6 +3097,14 @@ "esutils": "^2.0.2" } }, + "dom-helpers": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", + "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", + "requires": { + "@babel/runtime": "^7.1.2" + } + }, "domain-browser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", @@ -2694,6 +3126,11 @@ "stream-shift": "^1.0.0" } }, + "dynamic-public-path-webpack-plugin": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dynamic-public-path-webpack-plugin/-/dynamic-public-path-webpack-plugin-1.0.4.tgz", + "integrity": "sha1-5UUbG3AOhF8qQAnd96mo7byZRSc=" + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2780,7 +3217,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, "requires": { "is-arrayish": "^0.2.1" } @@ -2789,7 +3225,6 @@ "version": "1.17.4", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.4.tgz", "integrity": "sha512-Ae3um/gb8F0mui/jPL+QiqmglkUsaQf7FwBEHYIFkztkneosu9imhqHpBzQ3h1vit8t5iQ74t6PEVvphBZiuiQ==", - "dev": true, "requires": { "es-to-primitive": "^1.2.1", "function-bind": "^1.1.1", @@ -2808,7 +3243,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, "requires": { "is-callable": "^1.1.4", "is-date-object": "^1.0.1", @@ -3451,6 +3885,11 @@ "pkg-dir": "^3.0.0" } }, + "find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, "find-up": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", @@ -3528,6 +3967,16 @@ "readable-stream": "^2.0.0" } }, + "fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, "fs-readdir-recursive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz", @@ -4148,6 +4597,25 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==" }, + "globby": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", + "integrity": "sha1-9abXDoOV4hyFj7BInWTfAkJNUGw=", + "requires": { + "array-union": "^1.0.1", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" + } + } + }, "graceful-fs": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", @@ -4166,7 +4634,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, "requires": { "function-bind": "^1.1.1" } @@ -4238,6 +4705,14 @@ "minimalistic-crypto-utils": "^1.0.1" } }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "requires": { + "react-is": "^16.7.0" + } + }, "homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -4309,7 +4784,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz", "integrity": "sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==", - "dev": true, "requires": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -4429,8 +4903,7 @@ "is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", - "dev": true + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" }, "is-binary-path": { "version": "1.0.1", @@ -4448,8 +4921,7 @@ "is-callable": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz", - "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==", - "dev": true + "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==" }, "is-data-descriptor": { "version": "0.1.4", @@ -4472,8 +4944,7 @@ "is-date-object": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", - "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==", - "dev": true + "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==" }, "is-descriptor": { "version": "0.1.6", @@ -4534,6 +5005,27 @@ } } }, + "is-path-cwd": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", + "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==" + }, + "is-path-in-cwd": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz", + "integrity": "sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==", + "requires": { + "is-path-inside": "^2.1.0" + } + }, + "is-path-inside": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz", + "integrity": "sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==", + "requires": { + "path-is-inside": "^1.0.2" + } + }, "is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -4552,7 +5044,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz", "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==", - "dev": true, "requires": { "has": "^1.0.3" } @@ -4572,7 +5063,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", - "dev": true, "requires": { "has-symbols": "^1.0.1" } @@ -4647,12 +5137,20 @@ }, "dependencies": { "minimist": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", - "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" } } }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "requires": { + "graceful-fs": "^4.1.6" + } + }, "jsx-ast-utils": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-2.2.3.tgz", @@ -4699,6 +5197,11 @@ "type-check": "~0.3.2" } }, + "lines-and-columns": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", + "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=" + }, "load-json-file": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", @@ -4743,9 +5246,9 @@ } }, "minimist": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", - "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" } } }, @@ -4839,6 +5342,11 @@ "p-is-promise": "^2.0.0" } }, + "memoize-one": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.1.1.tgz", + "integrity": "sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA==" + }, "memory-fs": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz", @@ -5159,8 +5667,7 @@ "object-inspect": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", - "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==", - "dev": true + "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==" }, "object-keys": { "version": "1.1.1", @@ -5190,7 +5697,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.1.tgz", "integrity": "sha512-ilqR7BgdyZetJutmDPfXCDffGa0/Yzl2ivVNpbx/g4UeWrCdRnFDUBrKJGLhGieRHDATnyZXWBeCb29k9CJysQ==", - "dev": true, "requires": { "define-properties": "^1.1.3", "es-abstract": "^1.17.0-next.1", @@ -5334,6 +5840,11 @@ "p-limit": "^2.0.0" } }, + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==" + }, "p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -5358,7 +5869,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "requires": { "callsites": "^3.0.0" } @@ -5423,8 +5933,7 @@ "path-is-inside": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", - "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", - "dev": true + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=" }, "path-key": { "version": "2.0.1", @@ -5475,6 +5984,19 @@ "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==" }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=" + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "requires": { + "pinkie": "^2.0.0" + } + }, "pkg-dir": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", @@ -5488,6 +6010,11 @@ "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=" }, + "postcss-value-parser": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.0.3.tgz", + "integrity": "sha512-N7h4pG+Nnu5BEIzyeaaIYWs0LI5XC40OrRh5L60z0QjFsqGWcHcbkBvpe1WYpcIS9yQ8sOi/vIPt1ejQCrMVrg==" + }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -5597,6 +6124,16 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" }, + "query-string": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.11.1.tgz", + "integrity": "sha512-1ZvJOUl8ifkkBxu2ByVM/8GijMIPx+cef7u3yroO3Ogm4DOdZcF5dcrWTIlSHe3Pg/mtlt6/eFjObDfJureZZA==", + "requires": { + "decode-uri-component": "^0.2.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + } + }, "querystring": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", @@ -5661,11 +6198,58 @@ "scheduler": "^0.18.0" } }, + "react-input-autosize": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/react-input-autosize/-/react-input-autosize-2.2.2.tgz", + "integrity": "sha512-jQJgYCA3S0j+cuOwzuCd1OjmBmnZLdqQdiLKRYrsMMzbjUrVDS5RvJUDwJqA7sKuksDuzFtm6hZGKFu7Mjk5aw==", + "requires": { + "prop-types": "^15.5.8" + } + }, "react-is": { "version": "16.12.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.12.0.tgz", "integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q==" }, + "react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, + "react-select": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-3.0.8.tgz", + "integrity": "sha512-v9LpOhckLlRmXN5A6/mGGEft4FMrfaBFTGAnuPHcUgVId7Je42kTq9y0Z+Ye5z8/j0XDT3zUqza8gaRaI1PZIg==", + "requires": { + "@babel/runtime": "^7.4.4", + "@emotion/cache": "^10.0.9", + "@emotion/core": "^10.0.9", + "@emotion/css": "^10.0.9", + "memoize-one": "^5.0.0", + "prop-types": "^15.6.0", + "react-input-autosize": "^2.2.2", + "react-transition-group": "^2.2.1" + } + }, + "react-sparklines": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/react-sparklines/-/react-sparklines-1.7.0.tgz", + "integrity": "sha512-bJFt9K4c5Z0k44G8KtxIhbG+iyxrKjBZhdW6afP+R7EnIq+iKjbWbEFISrf3WKNFsda+C46XAfnX0StS5fbDcg==", + "requires": { + "prop-types": "^15.5.10" + } + }, + "react-transition-group": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz", + "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==", + "requires": { + "dom-helpers": "^3.4.0", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2", + "react-lifecycles-compat": "^3.0.4" + } + }, "read-pkg": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", @@ -5912,8 +6496,7 @@ "resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" }, "resolve-url": { "version": "0.2.1", @@ -6108,6 +6691,11 @@ "safe-buffer": "^5.0.1" } }, + "shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, "shebang-command": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", @@ -6324,6 +6912,11 @@ "integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==", "dev": true }, + "split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==" + }, "split-string": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", @@ -6405,6 +6998,11 @@ "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" }, + "strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY=" + }, "string-width": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", @@ -6444,7 +7042,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz", "integrity": "sha512-iu2AGd3PuP5Rp7x2kEZCrB2Nf41ehzh+goo8TV7z8/XDBbsvc6HQIlUl9RjkZ4oyrW1XM5UwlGl1oVEaDjg6Ag==", - "dev": true, "requires": { "define-properties": "^1.1.3", "function-bind": "^1.1.1" @@ -6454,7 +7051,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.1.tgz", "integrity": "sha512-qFvWL3/+QIgZXVmJBfpHmxLB7xsUXz6HsUmP8+5dRaC3Q7oKUv9Vo6aMCRZC1smrtyECFsIT30PqBJ1gTjAs+g==", - "dev": true, "requires": { "define-properties": "^1.1.3", "function-bind": "^1.1.1" @@ -6500,6 +7096,23 @@ "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", "dev": true }, + "styled-components": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.0.1.tgz", + "integrity": "sha512-E0xKTRIjTs4DyvC1MHu/EcCXIj6+ENCP8hP01koyoADF++WdBUOrSGwU1scJRw7/YaYOhDvvoad6VlMG+0j53A==", + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "@babel/traverse": "^7.4.5", + "@emotion/is-prop-valid": "^0.8.3", + "@emotion/stylis": "^0.8.4", + "@emotion/unitless": "^0.7.4", + "babel-plugin-styled-components": ">= 1", + "css-to-react-native": "^3.0.0", + "hoist-non-react-statics": "^3.0.0", + "shallowequal": "^1.1.0", + "supports-color": "^5.5.0" + } + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -6768,6 +7381,11 @@ "imurmurhash": "^0.1.4" } }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -7014,6 +7632,17 @@ } } }, + "webpack-manifest-plugin": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-2.2.0.tgz", + "integrity": "sha512-9S6YyKKKh/Oz/eryM1RyLVDVmy3NSPV0JXMRhZ18fJsq+AwGxUY34X54VNwkzYcEmEkDwNxuEOboCZEebJXBAQ==", + "requires": { + "fs-extra": "^7.0.0", + "lodash": ">=3.5 <5", + "object.entries": "^1.1.0", + "tapable": "^1.0.0" + } + }, "webpack-sources": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", @@ -7030,6 +7659,14 @@ } } }, + "webpack-subresource-integrity": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/webpack-subresource-integrity/-/webpack-subresource-integrity-1.4.0.tgz", + "integrity": "sha512-GB1kB/LwAWC3CxwcedGhMkxGpNZxSheCe1q+KJP1bakuieAdX/rGHEcf5zsEzhKXpqsGqokgsDoD9dIkr61VDQ==", + "requires": { + "webpack-sources": "^1.3.0" + } + }, "which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", @@ -7135,6 +7772,14 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, + "yaml": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.7.2.tgz", + "integrity": "sha512-qXROVp90sb83XtAoqE8bP9RwAkTTZbugRUTm5YeFCBfNRPEp2YzTeqWiz7m5OORHzEvrA/qcGS8hp/E+MMROYw==", + "requires": { + "@babel/runtime": "^7.6.3" + } + }, "yargs": { "version": "13.2.4", "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.2.4.tgz", diff --git a/package.json b/package.json index 78c41d11..b7b0cb22 100644 --- a/package.json +++ b/package.json @@ -24,13 +24,22 @@ "@babel/runtime": "^7.8.4", "@wordpress/babel-preset-default": "^4.10.0", "babel-loader": "^8.0.6", + "query-string": "^6.11.1", + "react-select": "^3.0.8", + "react-sparklines": "^1.7.0", + "styled-components": "^5.0.1", "deepmerge": "^4.2.2", "ua-parser-js": "^0.7.21", "webpack": "^4.41.6", "webpack-bundle-analyzer": "^3.6.0", - "webpack-cli": "^3.3.11" + "webpack-cli": "^3.3.11", + "dynamic-public-path-webpack-plugin": "^1.0.4", + "webpack-subresource-integrity": "^1.4.0", + "webpack-manifest-plugin": "^2.2.0", + "clean-webpack-plugin": "^3.0.0" }, "devDependencies": { + "@babel/plugin-proposal-class-properties": "^7.8.3", "babel-eslint": "^10.1.0", "eslint": "^5.16.0", "eslint-config-humanmade": "^0.8.0", diff --git a/plugin.php b/plugin.php index dcac8e89..8f2ecd74 100644 --- a/plugin.php +++ b/plugin.php @@ -18,7 +18,9 @@ require_once ROOT_DIR . '/vendor/autoload.php'; } -require_once 'inc/namespace.php'; -require_once 'inc/utils/namespace.php'; +require_once __DIR__ . '/inc/namespace.php'; +require_once __DIR__ . '/inc/audiences/namespace.php'; +require_once __DIR__ . '/inc/audiences/rest_api/namespace.php'; +require_once __DIR__ . '/inc/utils/namespace.php'; add_action( 'plugins_loaded', __NAMESPACE__ . '\\setup' ); diff --git a/src/audiences/edit/components/audience-editor.js b/src/audiences/edit/components/audience-editor.js new file mode 100644 index 00000000..87ea46e9 --- /dev/null +++ b/src/audiences/edit/components/audience-editor.js @@ -0,0 +1,109 @@ +import React, { Component } from 'react'; +import styled from 'styled-components'; + +import Group from './group'; +import SelectInclude from './select-include'; +import { + defaultAudience, + defaultGroup, +} from '../data/defaults'; + +const { __ } = wp.i18n; +const { Button } = wp.components; + +const StyledAudienceEditor = styled.div` + margin: 0 0 40px; + + .audience-editor__include { + margin: 20px 0; + } +`; + +export default class AudienceEditor extends Component { + onChangeInclude = e => { + this.props.onChange( { + ...this.props.audience, + include: e.target.value, + } ); + } + + onAddGroup = () => { + this.props.onChange( { + ...this.props.audience, + groups: [ + ...this.props.audience.groups, + defaultGroup, + ], + } ); + } + + onUpdateGroup = ( id, group = {} ) => { + const { audience } = this.props; + this.props.onChange( { + ...audience, + groups: [ + ...audience.groups.slice( 0, id ), + { + ...audience.groups[ id ], + ...group, + }, + ...audience.groups.slice( id + 1 ), + ], + } ); + } + + onRemoveGroup = id => { + const { audience } = this.props; + this.props.onChange( { + ...audience, + groups: [ + ...audience.groups.slice( 0, id ), + ...audience.groups.slice( id + 1 ), + ], + } ); + } + + render() { + const { audience } = this.props; + + return ( + +
+ +
+ + { audience.groups.map( ( group, groupId ) => ( + 1 } + namePrefix={ `audience[groups][${ groupId }]` } + title={ `${ __( 'Group' ) } ${ groupId + 1 }` } + onChange={ value => this.onUpdateGroup( groupId, value ) } + onRemove={ () => this.onRemoveGroup( groupId ) } + { ...group } + /> + ) ) } + + +
+ ); + } +} + +AudienceEditor.defaultProps = { + audience: defaultAudience, + fields: [], + onChange: () => {}, +}; diff --git a/src/audiences/edit/components/estimate.js b/src/audiences/edit/components/estimate.js new file mode 100644 index 00000000..f36d6f9d --- /dev/null +++ b/src/audiences/edit/components/estimate.js @@ -0,0 +1,91 @@ +import React from 'react'; +import { Sparklines, SparklinesLine } from 'react-sparklines'; +import styled from 'styled-components'; + +import PieChart from './pie-chart'; + +const { useSelect } = wp.data; +const { __ } = wp.i18n; + +const StyledEstimate = styled.div` + display: flex; + flex-wrap: wrap; + margin: 0; + + .audience-estimate__title { + flex: 0 0 100%; + margin: 0 0 20px; + } + + .audience-estimate__percentage { + flex: 0 1 100px; + margin-right: 20px; + } + + .audience-estimate__totals { + flex: 2; + } + + .audience-estimate__totals svg { + max-width: 220px; + margin-top: 5px; + margin-bottom: 10px; + } + + .audience-estimate__totals p { + margin: 0; + } + + .audience-estimate__totals strong { + margin-right: 2px; + } +`; + +export default function Estimate( props ) { + const { + audience, + sparkline, + title, + } = props; + + if ( ! audience ) { + return null; + } + + const estimate = useSelect( select => select( 'audience' ).getEstimate( audience ), [ audience ] ); + const percent = estimate.total ? Math.round( ( estimate.count / estimate.total ) * 100 ) : 0; + + return ( + + { title && ( +

{ title }

+ ) } + +
+ { sparkline && ( + item.count ) } + preserveAspectRatio="xMidYMid meet" + > + + + ) } +

+ { estimate.count } + { ' ' } + { __( 'uniques in the last 7 days' ) } +

+
+
+ ); +} + +Estimate.defaultProps = { + audience: null, + sparkline: false, + title: '', +}; diff --git a/src/audiences/edit/components/group.js b/src/audiences/edit/components/group.js new file mode 100644 index 00000000..a18afdf1 --- /dev/null +++ b/src/audiences/edit/components/group.js @@ -0,0 +1,135 @@ +import React, { Component } from 'react'; +import styled from 'styled-components'; + +import Rule from './rule'; +import SelectInclude from './select-include'; +import { defaultRule } from '../data/defaults'; + +const { __ } = wp.i18n; +const { Button } = wp.components; + +const StyledGroup = styled.div` + background: rgba(0, 0, 0, 0.02); + border-radius: 3px; + border: 1px solid rgba(0, 0, 0, .1); + padding: 20px; + margin: 0 0 20px; + + .audience-editor__group-header { + margin: 0 0 15px; + display: flex; + align-items: baseline; + } + + h3 { + margin: 0 20px 0 0; + text-transform: lowercase; + font-variant: small-caps; + } + + .audience-editor__group-footer .components-button { + margin-right: 10px; + } +`; + +export default class Group extends Component { + onAddRule = () => { + this.props.onChange( { + rules: [ + ...this.props.rules, + defaultRule, + ], + } ); + }; + + onChangeRule = e => { + this.props.onChange( { + include: e.target.value, + } ); + }; + + onRemoveRule = id => { + const { rules } = this.props; + this.props.onChange( { + rules: [ + ...rules.slice( 0, id ), + ...rules.slice( id + 1 ), + ], + } ); + }; + + updateRule = ( ruleId, rule ) => { + const { rules } = this.props; + this.props.onChange( { + rules: [ + ...rules.slice( 0, ruleId ), + { + ...rules[ ruleId ], + ...rule, + }, + ...rules.slice( ruleId + 1 ), + ], + } ); + }; + + render() { + const { + canRemove, + include, + fields, + namePrefix, + rules, + title, + onRemove, + } = this.props; + + return ( + +
+ { title && ( +

{ title }

+ ) } + +
+ + { rules.map( ( rule, ruleId ) => ( + 1 } + fields={ fields } + namePrefix={ `${ namePrefix }[rules][${ ruleId }]` } + onChange={ value => this.updateRule( ruleId, value ) } + onRemove={ () => this.onRemoveRule( ruleId ) } + { ...rule } + /> + ) ) } + +
+ + + { canRemove && ( + + ) } +
+
+ ); + } +} diff --git a/src/audiences/edit/components/pie-chart.js b/src/audiences/edit/components/pie-chart.js new file mode 100644 index 00000000..6c06f370 --- /dev/null +++ b/src/audiences/edit/components/pie-chart.js @@ -0,0 +1,39 @@ +import React from 'react'; +import styled from 'styled-components'; + +const StyledPie = styled.svg` + circle { + fill: transparent; + stroke: ${ props => props.stroke || 'rgb(0, 124, 186)' }; + stroke-linecap: round; + stroke-opacity: 0.2; + stroke-dasharray: 101 100; + stroke-dashoffset: 0; + stroke-width: 3; + transform: rotate(-90deg); + transform-origin: center; + transition: stroke-dasharray 0.3s ease-in-out; + } + circle[data-percent] { + stroke-dasharray: ${ props => props.percent === 100 ? 101 : props.percent } 100; + stroke-opacity: 1; + } + text { + stroke-width: 0; + font-size: 9px; + } +`; + +export default function PieChart( props ) { + return ( + + + + { props.percent }% + + ); +} + +PieChart.defaultProps = { + percent: 0, +}; diff --git a/src/audiences/edit/components/rule.js b/src/audiences/edit/components/rule.js new file mode 100644 index 00000000..d4a29e42 --- /dev/null +++ b/src/audiences/edit/components/rule.js @@ -0,0 +1,168 @@ +import React from 'react'; +import styled from 'styled-components'; + +import SelectOperator from './select-operator'; + +const { Button } = wp.components; +const { useSelect } = wp.data; +const { __ } = wp.i18n; + +const StyledRule = styled.div` + margin: 0 0 15px; + display: flex; + + select, input { + flex: 1; + } + + && > * + * { + margin-left: 5px; + } + + .audience-editor__rule-operator, + button { + flex 0; + } +`; + +const RuleInput = props => { + const { + disabled, + currentField, + name, + operator, + value, + onChange, + } = props; + + switch ( currentField.type ) { + case 'number': + return ( + + ); + + case 'string': + default: + switch ( operator ) { + case '=': + case '!=': + return ( + + ); + + case '*=': + case '!*': + case '^=': + return ( + + ); + + default: + return null; + } + } +}; + +export default function Rule( props ) { + const { + canRemove, + field, + namePrefix, + operator, + value, + onChange, + onRemove, + } = props; + + const fields = useSelect( select => select( 'audience' ).getFields(), [] ); + const currentField = fields.find( fieldData => fieldData.name === field ) || {}; + + return ( + + + + onChange( { operator: e.target.value } ) } + type={ currentField.type || 'string' } + disabled={ fields.length === 0 } + /> + + onChange( { value: e.target.value } ) } + /> + + { canRemove && ( + + ) } + + ); +} diff --git a/src/audiences/edit/components/select-include.js b/src/audiences/edit/components/select-include.js new file mode 100644 index 00000000..d6ec5364 --- /dev/null +++ b/src/audiences/edit/components/select-include.js @@ -0,0 +1,27 @@ +import React from 'react'; +import styled from 'styled-components'; + +const { __ } = wp.i18n; + +const StyledSelect = styled.select` + vertical-align: middle; + + &:not(:hover, :focus) { + border: none; + background: none; + margin-right: 8px; + } +`; + +export default function SelectInclude( props ) { + return ( + + + + + ); +} + +SelectInclude.defaultProps = { + label: '', +}; diff --git a/src/audiences/edit/components/select-operator.js b/src/audiences/edit/components/select-operator.js new file mode 100644 index 00000000..2aa9a3e0 --- /dev/null +++ b/src/audiences/edit/components/select-operator.js @@ -0,0 +1,26 @@ +import React from 'react'; + +import { + NUMERIC_OPERATIONS, + STRING_OPERATIONS, +} from '../data/constants'; + +export default function SelectOperator( props ) { + const { + type = 'string', + } = props; + + let options = STRING_OPERATIONS; + + if ( type === 'number' ) { + options = NUMERIC_OPERATIONS; + } + + return ( + + ); +} diff --git a/src/audiences/edit/data/constants.js b/src/audiences/edit/data/constants.js new file mode 100644 index 00000000..68a138bc --- /dev/null +++ b/src/audiences/edit/data/constants.js @@ -0,0 +1,16 @@ +const { __ } = wp.i18n; + +export const STRING_OPERATIONS = { + '=': __( 'is', 'altis-analytics' ), + '!=': __( 'is not', 'altis-analytics' ), + '*=': __( 'contains', 'altis-analytics' ), + '!*': __( 'does not contain', 'altis-analytics' ), + '^=': __( 'begins with', 'altis-analytics' ), +}; + +export const NUMERIC_OPERATIONS = { + 'gt': __( 'is greater than', 'altis-analytics' ), + 'gte': __( 'is greater than or equal to', 'altis-analytics' ), + 'lt': __( 'is less than', 'altis-analytics' ), + 'lte': __( 'is less than or equal to', 'altis-analytics' ), +}; diff --git a/src/audiences/edit/data/defaults.js b/src/audiences/edit/data/defaults.js new file mode 100644 index 00000000..b164b7ac --- /dev/null +++ b/src/audiences/edit/data/defaults.js @@ -0,0 +1,26 @@ +export const defaultRule = { + field: '', + operator: '=', // =, !=, *, !* + value: '', // mixed + type: 'string', // data type, string or number +}; + +export const defaultGroup = { + include: 'any', // ANY, ALL, NONE + rules: [ + defaultRule, + ], +}; + +export const defaultAudience = { + include: 'all', // ANY, ALL, NONE + groups: [ + defaultGroup, + ], +}; + +export const defaultPost = { + title: { rendered: '' }, + audience: defaultAudience, + status: 'draft', +}; diff --git a/src/audiences/edit/data/index.js b/src/audiences/edit/data/index.js new file mode 100644 index 00000000..8506bf18 --- /dev/null +++ b/src/audiences/edit/data/index.js @@ -0,0 +1,129 @@ +import { + defaultAudience, + defaultPost, +} from './defaults'; +import reducer from './reducer'; + +const { apiFetch } = wp; +const { registerStore } = wp.data; + +const initialState = { + estimates: {}, + fields: [], + pagination: {}, + post: defaultPost, + posts: [], +}; + +// Hydrate from server side. +if ( window.Altis.Analytics.Audiences.Fields ) { + initialState.fields = window.Altis.Analytics.Audiences.Fields; +} +if ( window.Altis.Analytics.Audiences.Current ) { + initialState.posts.push( window.Altis.Analytics.Audiences.Current ); + initialState.post = window.Altis.Analytics.Audiences.Current; +} + +const controls = { + FETCH_FROM_API( action ) { + return apiFetch( action.options ); + }, +}; + +const actions = { + setFields( fields ) { + return { + type: 'SET_FIELDS', + fields, + }; + }, + addPosts( posts ) { + return { + type: 'ADD_POSTS', + posts, + }; + }, + addEstimate( audience, estimate ) { + return { + type: 'ADD_ESTIMATE', + audience, + estimate, + }; + }, + setPost( post ) { + return { + type: 'SET_POST', + post, + }; + }, + fetch( options ) { + return { + type: 'FETCH_FROM_API', + options, + }; + }, +}; + +const selectors = { + getFields( state ) { + return state.fields; + }, + getEstimate( state, audience ) { + const key = JSON.stringify( audience ); + return state.estimates[ key ] || { + count: 0, + total: 0, + histogram: new Array( 28 ).fill( { count: 1 } ), // Build empty histrogram data. + }; + }, + getPost( state ) { + return state.post; + }, + getPosts( state ) { + return state.posts; + }, +}; + +const resolvers = { + *getFields() { + const fields = yield actions.fetch( { + path: 'analytics/v1/audiences/fields', + } ); + return actions.setFields( fields ); + }, + *getEstimate( audience ) { + const audienceQuery = encodeURIComponent( JSON.stringify( audience ) ); + const estimate = yield actions.fetch( { + path: `analytics/v1/audiences/estimate?audience=${ audienceQuery }`, + } ); + return actions.addEstimate( audience, estimate ); + }, + *getPost( id ) { + const post = yield actions.fetch( { + path: `wp/v2/audiences/${ id }?context=edit`, + } ); + if ( post.status === 'auto-draft' ) { + post.title.rendered = ''; + } + if ( ! post.audience ) { + post.audience = defaultAudience; + } + yield actions.addPosts( [ post ] ); + return actions.setPost( post ); + }, + *getPosts( page = 1, search = '' ) { + const posts = yield actions.fetch( { + path: `wp/v2/audiences?context=edit&page=${ page }&search=${ search }`, + } ); + return actions.addPosts( posts ); + }, +}; + +export const store = registerStore( 'audience', { + actions, + controls, + initialState, + reducer, + resolvers, + selectors, +} ); diff --git a/src/audiences/edit/data/reducer.js b/src/audiences/edit/data/reducer.js new file mode 100644 index 00000000..474cdbbb --- /dev/null +++ b/src/audiences/edit/data/reducer.js @@ -0,0 +1,69 @@ +import { unionBy } from 'lodash'; + +export default function reducer( state, action ) { + switch ( action.type ) { + case 'SET_FIELDS': { + return { + ...state, + fields: action.fields, + }; + } + + case 'ADD_ESTIMATE': { + const key = JSON.stringify( action.audience ); + if ( state.estimates[ key ] ) { + return state; + } + return { + ...state, + estimates: { + ...state.estimates, + [ key ]: action.estimate, + }, + }; + } + + case 'ADD_POSTS': { + return { + ...state, + posts: unionBy( [ state.posts, action.posts ], post => post.id ), + }; + } + + case 'SET_POST': { + if ( ! action.post.id ) { + return { + ...state, + post: { + ...state.post, + ...action.post, + }, + }; + } + + const posts = state.posts.map( post => { + if ( post.id !== action.post.id ) { + return post; + } + + return { + ...post, + ...action.post, + }; + } ); + + return { + ...state, + posts, + post: { + ...state.post, + ...action.post, + }, + }; + } + + default: { + return state; + } + } +} diff --git a/src/audiences/edit/index.js b/src/audiences/edit/index.js new file mode 100644 index 00000000..84c51116 --- /dev/null +++ b/src/audiences/edit/index.js @@ -0,0 +1,232 @@ +import React, { Component } from 'react'; +import styled from 'styled-components'; + +import AudienceEditor from './components/audience-editor'; +import Estimate from './components/estimate'; +import { defaultPost, defaultAudience } from './data/defaults'; + +const { compose } = wp.compose; +const { + withSelect, + withDispatch, +} = wp.data; +const { __ } = wp.i18n; +const { + Button, + ToggleControl, + Notice, + Snackbar, +} = wp.components; + +const formElement = document.querySelector( '.post-type-audience #post' ); + +const StyledEdit = styled.div` + margin: 20px 0; + + .components-notice { + margin: 0 0 20px; + } + + .audience-settings { + display: flex; + } + + .audience-settings > * { + flex: 1; + } + + .audience-options { + flex: 0 1 320px; + margin: 20px 0 20px 40px; + } + + .audience-estimate { + margin-bottom: 40px; + } +`; + +class Edit extends Component { + state = { + notice: null, + error: null, + }; + + componentDidMount() { + // Get the form element if there is one. This is for back compat with + // the legacy post edit screen. + if ( formElement ) { + formElement.addEventListener( 'submit', this.onSubmit ); + } + } + + componentWillUnmount() { + if ( formElement ) { + formElement.removeEventListener( 'submit', this.onSubmit ); + } + } + + static getDerivedStateFromError( error ) { + return { error }; + } + + componentDidCatch( error, errorInfo ) { + console.error( error, errorInfo ); + } + + onSubmit = event => { + // Clear errors. + this.setState( { error: null } ); + + const { post } = this.props; + + if ( post && post.title.rendered.length === 0 ) { + event.preventDefault(); + this.setState( { + error: __( 'The title cannot be empty', 'altis-analytics' ), + } ); + return; + } + + // Prevent the "Leave site?" warning popup showing. + window.onbeforeunload = null; + window.jQuery && window.jQuery( window ).off( 'beforeunload' ); + } + + render() { + const { + loading, + post, + onSetTitle, + onSetAudience, + onSetStatus, + } = this.props; + + const { + error, + notice, + } = this.state; + + return ( + + { error && ( + this.setState( { error: null } ) } + > + { error.toString() } + + ) } + { notice && ( + this.setState( { notice: null } ) } + > +

{ notice }

+
+ ) } + +
+ onSetTitle( e.target.value ) } + /> +
+ +
+ + +
+ +

{ __( 'Audience options', 'altis-analytics' ) }

+ onSetStatus( post.status === 'publish' ? 'draft' : 'publish' ) } + /> + + +
+
+
+ ); + } +} + +Edit.defaultProps = { + postId: null, + post: defaultPost, + loading: false, + onCreate: () => { }, + onSetTitle: () => { }, + onSetAudience: () => { }, + onSetStatus: () => { }, +}; + +const applyWithSelect = withSelect( ( select, props ) => { + let post = props.post; + + if ( props.postId ) { + post = select( 'audience' ).getPost( props.postId ); + } + + // If we have a post ID but no post then we're loading. + const loading = props.postId && ! post.id; + + return { + post, + loading, + }; +} ); + +const applyWithDispatch = withDispatch( dispatch => { + const store = dispatch( 'audience' ); + + return { + onSetTitle: value => { + store.setPost( { title: { rendered: value } } ); + }, + onSetAudience: value => { + store.setPost( { audience: value } ); + }, + onSetStatus: value => { + store.setPost( { status: value } ); + }, + }; +} ); + +export default compose( + applyWithDispatch, + applyWithSelect, +)( Edit ); diff --git a/src/audiences/index.css b/src/audiences/index.css new file mode 100644 index 00000000..d4ffb753 --- /dev/null +++ b/src/audiences/index.css @@ -0,0 +1,15 @@ +/** + * Audiences admin listing styles. + */ + +.post-type-audience .wp-list-table th#last_modified { + width: 10em; +} + +.post-type-audience .wp-list-table .audience-estimate__percentage { + max-width: 60px; +} + +.post-type-audience .wp-list-table .audience-estimate .audience-estimate__totals svg { + max-width: 120px; +} diff --git a/src/audiences/index.js b/src/audiences/index.js new file mode 100644 index 00000000..e01df932 --- /dev/null +++ b/src/audiences/index.js @@ -0,0 +1,38 @@ +// Audience UI Application. +import React from 'react'; +import ReactDOM from 'react-dom'; + +import Edit from './edit'; +import Estimate from './edit/components/estimate'; + +// Import the data store file directly. +import './edit/data'; + +// Get the audience UI placeholder. +const AudienceUI = document.getElementById( 'altis-analytics-audience-ui' ); + +// Is our audience UI placeholder present? +if ( AudienceUI ) { + // Mount audience react app. + ReactDOM.render( + , + AudienceUI + ); +} + +// Get the audience UI placeholder. +const AudienceEstimates = document.querySelectorAll( '.altis-analytics-audience-estimate' ); + +// Render any estimate blocks on page. +AudienceEstimates.forEach( element => { + // Mount audience react app. + ReactDOM.render( + , + element + ); +} ); diff --git a/webpack.config.js b/webpack.config.js index 52fc0bc6..665e4af7 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,19 +1,27 @@ const path = require( 'path' ); +const webpack = require( 'webpack' ); const mode = process.env.NODE_ENV || 'production'; const BundleAnalyzerPlugin = require( 'webpack-bundle-analyzer' ) .BundleAnalyzerPlugin; +const DynamicPublicPathPlugin = require( 'dynamic-public-path-webpack-plugin' ); +const SriPlugin = require( 'webpack-subresource-integrity' ); +const ManifestPlugin = require( 'webpack-manifest-plugin' ); +const { CleanWebpackPlugin } = require( 'clean-webpack-plugin' ); const sharedConfig = { mode: mode, entry: { analytics: path.resolve( __dirname, 'src/analytics.js' ), + audiences: path.resolve( __dirname, 'src/audiences/index.js' ), }, output: { path: path.resolve( __dirname, 'build' ), - filename: '[name].js', - publicPath: '.', + filename: '[name].[hash:8].js', + chunkFilename: 'chunk.[id].[chunkhash:8].js', + publicPath: '/', libraryTarget: 'this', - jsonpFunction: 'HManalyticsJSONP', + jsonpFunction: 'AltisAnalyticsJSONP', + crossOriginLoading: 'anonymous', }, module: { rules: [ @@ -29,6 +37,7 @@ const sharedConfig = { ], plugins: [ require( '@babel/plugin-transform-runtime' ), + require( '@babel/plugin-proposal-class-properties' ), require( '@wordpress/babel-plugin-import-jsx-pragma' ), ], }, @@ -39,12 +48,15 @@ const sharedConfig = { optimization: { noEmitOnErrors: true, }, - plugins: [], - devtool: ( - mode === 'production' - ? 'cheap-module-source-map' - : 'cheap-module-eval-source-map' - ), + plugins: [ + new webpack.EnvironmentPlugin( { + SC_ATTR: 'data-styled-components-altis-analytics', + } ), + new ManifestPlugin( { + writeToFileEmit: true, + } ), + new CleanWebpackPlugin(), + ], externals: { 'Altis': 'Altis', 'wp': 'wp', @@ -53,6 +65,19 @@ const sharedConfig = { }, }; +if ( mode === 'production' ) { + sharedConfig.plugins.push( new DynamicPublicPathPlugin( { + externalGlobal: 'window.Altis.Analytics.BuildURL', + chunkName: 'audiences', + } ) ); + sharedConfig.plugins.push( new SriPlugin( { + hashFuncNames: [ 'sha384' ], + enabled: true, + } ) ); +} else { + sharedConfig.devtool = 'cheap-module-eval-source-map'; +} + if ( process.env.ANALYSE_BUNDLE ) { // Add bundle analyser. sharedConfig.plugins.push( new BundleAnalyzerPlugin() );