diff --git a/src/class-tiny-dashboard.php b/src/class-tiny-dashboard.php new file mode 100644 index 00000000..196f3339 --- /dev/null +++ b/src/class-tiny-dashboard.php @@ -0,0 +1,177 @@ +settings = $settings; + $this->background_optimizer = $background_optimizer; + } + + public function ajax_init() { + add_action( + 'wp_ajax_tiny_dashboard_widget', + array( $this, 'ajax_dashboard_widget' ) + ); + } + + public function admin_init() { + if ( current_user_can( 'upload_files' ) ) { + add_action( + 'wp_dashboard_setup', + array( $this, 'add_dashboard_widget' ) + ); + } + } + + public function add_dashboard_widget() { + wp_enqueue_style( + self::NAME . '_dashboard_widget', + plugins_url( '/css/dashboard-widget.css', __FILE__ ), + array(), + Tiny_Plugin::version() + ); + + wp_enqueue_script( + self::NAME . '_dashboard_widget', + plugins_url( '/js/dashboard-widget.js', __FILE__ ), + array(), + Tiny_Plugin::version(), + true + ); + + wp_add_dashboard_widget( + $this->get_prefixed_name( 'dashboard_widget' ), + esc_html__( 'TinyPNG - JPEG, PNG & WebP image compression', 'tiny-compress-images' ), + array( $this, 'add_widget_view' ) + ); + } + + /** + * The statistics scan the whole library, so the dashboard shows a spinner + * and the widget JS fetches the real content from ajax_dashboard_widget(). + */ + public function add_widget_view() { + include __DIR__ . '/views/dashboard-widget-loading.php'; + } + + public function ajax_dashboard_widget() { + if ( ! check_ajax_referer( 'tiny-dashboard-widget', '_nonce', false ) || + ! current_user_can( 'upload_files' ) ) { + status_header( 403 ); + wp_die( -1, 403 ); + } + + $stats = Tiny_Bulk_Optimization::get_optimization_statistics( $this->settings ); + $widget = $this->get_widget_data( $stats ); + include __DIR__ . '/views/dashboard-widget.php'; + wp_die(); + } + + /** + * Logic for the view + * + * @param array $optimization_stats See Tiny_Bulk_Optimization::get_optimization_statistics(). + * @return array + */ + public function get_widget_data( $optimization_stats ) { + $images_total = max( 0, intval( $optimization_stats['uploaded-images'] ) ); + $images_remaining = min( + $images_total, + count( $optimization_stats['available-for-optimization'] ) + ); + $images_optimized = $images_total - $images_remaining; + $bytes_saved = $optimization_stats['unoptimized-library-size'] + - $optimization_stats['optimized-library-size']; + + $percentage = $images_total > 0 + ? intval( floor( $images_optimized / $images_total * 100 ) ) + : 0; + + $estimated_credit_use = intval( $optimization_stats['estimated_credit_use'] ); + + // Only free accounts have a limited number of credits, including + // free accounts that bought a credit bundle. + $remaining_credits = $this->settings->get_remaining_credits(); + $has_limited_credits = $this->settings->is_on_free_plan() && + is_numeric( $remaining_credits ); + $remaining_credits = $has_limited_credits ? intval( $remaining_credits ) : null; + + // How many of the remaining images the credits still cover. + $images_affordable = 0; + if ( $has_limited_credits && $images_remaining > 0 && $estimated_credit_use > 0 ) { + $images_affordable = intval( + floor( $remaining_credits / ( $estimated_credit_use / $images_remaining ) ) + ); + } + + $seconds_left = null; + if ( ! $this->settings->has_api_key() ) { + $state = 'not_connected'; + } elseif ( $this->settings->refresh_api_key_pending() ) { + $state = 'pending'; + } elseif ( 0 === $images_total ) { + $state = 'empty'; + } elseif ( $this->background_optimizer->is_running() ) { + $state = 'compressing'; + + $seconds_left = intval( + round( $estimated_credit_use / Tiny_Background_Optimize::WORKERS ) + ); + } elseif ( 0 === $images_remaining ) { + $state = 'done'; + } elseif ( 0 === $remaining_credits ) { + $state = 'out_of_credits'; + } elseif ( $has_limited_credits && $estimated_credit_use > $remaining_credits ) { + $state = 'credits_short'; + } else { + $state = 'ready'; + } + + return array( + 'state' => $state, + 'images_optimized' => $images_optimized, + 'images_total' => $images_total, + 'images_remaining' => $images_remaining, + 'images_affordable' => $images_affordable, + 'bytes_saved' => max( 0, intval( $bytes_saved ) ), + 'percentage' => $percentage, + 'estimated_credit_use' => $estimated_credit_use, + 'remaining_credits' => $remaining_credits, + 'seconds_left' => $seconds_left, + 'email_address' => $this->settings->get_email_address(), + 'auto_compress' => $this->settings->auto_compress_enabled(), + ); + } +} diff --git a/src/class-tiny-plugin.php b/src/class-tiny-plugin.php index 80e006ad..4a00d97b 100644 --- a/src/class-tiny-plugin.php +++ b/src/class-tiny-plugin.php @@ -48,6 +48,7 @@ public function __construct() { $this->settings = new Tiny_Settings(); new Tiny_Conversion( $this->settings ); $this->background_optimizer = new Tiny_Background_Optimize( $this->settings ); + new Tiny_Dashboard( $this->settings, $this->background_optimizer ); } public function set_compressor( $compressor ) { @@ -125,11 +126,6 @@ public function ajax_init() { $this->get_method( 'ajax_bulk_queue_optimize_next' ) ); - add_action( - 'wp_ajax_tiny_get_optimization_statistics', - $this->get_method( 'ajax_optimization_statistics' ) - ); - add_action( 'wp_ajax_tiny_get_compression_status', $this->get_method( 'ajax_compression_status' ) @@ -178,11 +174,6 @@ public function ajax_init() { } public function admin_init() { - add_action( - 'wp_dashboard_setup', - $this->get_method( 'add_dashboard_widget' ) - ); - add_action( 'admin_enqueue_scripts', $this->get_method( 'enqueue_scripts' ) @@ -803,15 +794,6 @@ public function ajax_bulk_queue_cancel() { wp_send_json_success(); } - public function ajax_optimization_statistics() { - if ( check_ajax_referer( 'tiny-compress', '_nonce', false ) && - current_user_can( 'upload_files' ) ) { - $stats = Tiny_Bulk_Optimization::get_optimization_statistics( $this->settings ); - echo json_encode( $stats ); - } - exit(); - } - public function ajax_compression_status() { $response = $this->validate_ajax_attachment_request(); @@ -952,61 +934,13 @@ public function render_bulk_optimization_page() { $email_address = $this->settings->get_email_address(); $bulk_running = $this->background_optimizer->is_running(); - include __DIR__ . '/views/bulk-optimization.php'; - } - - public function add_dashboard_widget() { - wp_enqueue_style( - self::NAME . '_chart', - plugins_url( '/css/optimization-chart.css', __FILE__ ), - array(), - self::version() - ); - - wp_enqueue_style( - self::NAME . '_dashboard_widget', - plugins_url( '/css/dashboard-widget.css', __FILE__ ), - array(), - self::version() - ); - - wp_register_script( - self::NAME . '_dashboard_widget', - plugins_url( '/js/dashboard-widget.js', __FILE__ ), - array(), - self::version(), - true + /* The dashboard widget links here with a nonce to start right away. */ + $start_on_load = isset( $_GET['start'], $_GET['_wpnonce'] ) && wp_verify_nonce( + sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), + 'tiny-bulk-start' ); - /* - This might be deduplicated with the admin script localization, but - the order of including scripts is sometimes different. So in that - case we need to make sure that the order of inclusion is correct. */ - wp_localize_script( - self::NAME . '_dashboard_widget', - 'tinyCompressDashboard', - array( - 'nonce' => wp_create_nonce( 'tiny-compress' ), - ) - ); - - wp_enqueue_script( self::NAME . '_dashboard_widget' ); - - $widget_title = esc_html__( - 'TinyPNG Image Optimization – Image Compression JPEG & PNG | Convert to WebP & AVIF', - 'tiny-compress-images' - ); - - wp_add_dashboard_widget( - $this->get_prefixed_name( 'dashboard_widget' ), - $widget_title, - $this->get_method( 'add_widget_view' ) - ); - } - - public function add_widget_view() { - $admin_colors = self::retrieve_admin_colors(); - include __DIR__ . '/views/dashboard-widget.php'; + include __DIR__ . '/views/bulk-optimization.php'; } private static function retrieve_admin_colors() { diff --git a/src/class-tiny-settings.php b/src/class-tiny-settings.php index 8f442ea5..045e38e7 100644 --- a/src/class-tiny-settings.php +++ b/src/class-tiny-settings.php @@ -224,6 +224,10 @@ protected function get_api_key() { } } + public function has_api_key() { + return (bool) $this->get_api_key(); + } + protected function get_api_key_pending() { if ( defined( 'TINY_API_KEY' ) ) { return false; @@ -236,6 +240,24 @@ protected function clear_api_key_pending() { delete_option( self::get_prefixed_name( 'api_key_pending' ) ); } + /** + * Whether the API key still waits for its activation link to be opened. + * Asks the API only while the key is pending, and clears the flag once + * the key works. + * + * @return bool + */ + public function refresh_api_key_pending() { + if ( ! $this->get_api_key_pending() ) { + return false; + } + if ( $this->compressor->get_status()->ok ) { + $this->clear_api_key_pending(); + return false; + } + return true; + } + protected static function get_intermediate_size( $size ) { /* Inspired by @@ -846,18 +868,18 @@ public function render_account_status() { include __DIR__ . '/views/account-status-create-simple.php'; } } else { - $status = $this->compressor->get_status(); - $status->pending = false; - if ( $status->ok ) { - if ( $this->get_api_key_pending() ) { - $this->clear_api_key_pending(); - } - } elseif ( $this->get_api_key_pending() ) { - $status->ok = true; - $status->pending = true; - $status->message = ( - 'An email has been sent to activate your account' - ); + if ( $this->refresh_api_key_pending() ) { + $status = (object) array( + 'ok' => true, + 'pending' => true, + 'message' => __( + 'An email has been sent to activate your account', + 'tiny-compress-images' + ), + ); + } else { + $status = $this->compressor->get_status(); + $status->pending = false; } include __DIR__ . '/views/account-status-connected.php'; } diff --git a/src/css/dashboard-widget.css b/src/css/dashboard-widget.css index bf4b9a80..02248212 100644 --- a/src/css/dashboard-widget.css +++ b/src/css/dashboard-widget.css @@ -1,129 +1,315 @@ -div.tiny_dashboard_widget div.inside { - min-height: 200px; +#tinypng_dashboard_widget .inside { margin: 0; - padding-bottom: 0; + padding: 0; +} + +.tiny-widget-loading { + padding: 24px 12px; +} + +.tiny-widget-loading .spinner { + float: none; + margin: 0; +} + +.tiny-widget { + container-type: inline-size; overflow: hidden; + color: #12141d; + font-size: 14px; + line-height: 20px; } -div.tiny_dashboard_widget div.inside div.spinner { - position: absolute; - visibility: visible; - z-index: 10; - top: 30%; - left: 50%; - margin-left: -12px; +.tiny-widget [hidden] { + display: none; } -div.tiny_dashboard_widget div.inside div.sky { - position: absolute; - left: 0; - top: 0; - height: 100%; - width: 100%; - opacity: 0.3; - background: linear-gradient(180deg, #c6dde4 0%, #fefeff 100%); - /* ie6-9 */ filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#33C6DDE4', endColorstr='#33FEFEFF',GradientType=0 ); +.tiny-widget p { + margin: 0; + color: #5b5b67; +} + +.tiny-widget-main, +.tiny-widget-credits { + display: flex; + flex-direction: column; + gap: 8px; + padding: 24px 12px; + animation: tiny-widget-slide-from-left 0.25s ease-out; +} + +.tiny-widget-credits { + animation-name: tiny-widget-slide-from-right; +} + +@keyframes tiny-widget-slide-from-left { + from { + opacity: 0; + transform: translateX(-24px); + } +} + +@keyframes tiny-widget-slide-from-right { + from { + opacity: 0; + transform: translateX(24px); + } +} + +@media (prefers-reduced-motion: reduce) { + .tiny-widget-main, + .tiny-widget-credits { + animation: none; + } +} + +.tiny-widget-top { + display: flex; + align-items: flex-start; + gap: 16px; +} + +.tiny-widget-content { + display: flex; + flex: 1; + flex-direction: column; + align-items: flex-start; + gap: 4px; + min-width: 0; +} + +.tiny-widget-panda { + flex-shrink: 0; +} + +.tiny-widget-badge { + padding: 2px 10px; + border-radius: 9999px; + font-size: 13px; + font-weight: 600; + margin-bottom: 8px; +} + +.tiny-widget-badge-red { + background: #fef2f2; + color: #dc2626; +} + +.tiny-widget-badge-amber { + background: #fef3c7; + color: #b45309; +} + +.tiny-widget-badge-green { + background: #e8faf0; + color: #00b075; +} + +.tiny-widget-hero { + font-size: 36px; + line-height: 40px; + font-weight: 600; + letter-spacing: -0.36px; +} + +.tiny-widget-hero span { + display: block; + color: #5b5b67; + font-size: 16px; + line-height: 24px; + font-weight: 400; + letter-spacing: 0; } -div.tiny_dashboard_widget div.inside div.cloud { - position: absolute; - right: 0; - top: 0; - height: 240px; - width: 298px; - background-image: url("../images/cloud.png"); - background-size: cover; +#tinypng_dashboard_widget .tiny-widget-heading { + margin: 0; + padding: 0; + font-size: 20px; + line-height: 28px; + font-weight: 600; +} + +.tiny-widget-below { + display: flex; + flex-direction: column; + gap: 12px; +} + +.tiny-widget-library { + display: flex; + flex-direction: column; + gap: 8px; } -div.tiny_dashboard_widget div.inside div.grass { - position: absolute; - right: 0; - bottom: 0; - height: 24px; - width: 536px; - background-image: url("../images/grass.png"); - background-size: cover; +.tiny-widget-bar { + height: 10px; + overflow: hidden; + border-radius: 9999px; + background: #d4d4db; } -div.tiny_dashboard_widget div.inside div.panda { - position: absolute; - bottom: 0; - background-repeat: no-repeat; - -webkit-background-size: 100%; - background-size: cover; - background-position: bottom; +.tiny-widget-bar-fill { + height: 100%; + border-radius: 9999px; + background: #06d28b; } -div.tiny_dashboard_widget.full-optimized div.inside div.panda { - right: 10%; - width: 305px; - height: 105.5px; - margin-right: 0; - background-image: url("../images/panda-laying.png"); +.tiny-widget-legend { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 6px 16px; + color: #5b5b67; } -div.tiny_dashboard_widget.half-optimized div.inside div.panda { - right: -230px; - width: 255px; - height: 203px; - margin-right: 45%; - background-image: url("../images/panda-eating.png"); +.tiny-widget-legend span { + display: inline-flex; + align-items: center; + gap: 6px; } -div.tiny_dashboard_widget.not-optimized div.inside div.panda, -div.tiny_dashboard_widget.no-images-uploaded div.inside div.panda { - right: -196px; - width: 241px; - height: 203px; - margin-right: 45%; - background-image: url("../images/panda-waiting.png"); +.tiny-widget-legend strong { + color: #12141d; + font-weight: 600; } -div.tiny_dashboard_widget.no-images-uploaded div.inside div.no-images, -div.tiny_dashboard_widget.not-optimized div.inside div.not-optimized, -div.tiny_dashboard_widget.half-optimized div.inside div.half-optimized, -div.tiny_dashboard_widget.full-optimized div.inside div.full-optimized { - display: block !important; - position: relative; - z-index: 1; +.tiny-widget-swatch { + width: 10px; + height: 10px; + border-radius: 2px; + background: #06d28b; } -div.tiny_dashboard_widget div.inside div#optimization-chart { - margin-top: 12px; - margin-bottom: 40px; - min-width: 160px; +.tiny-widget-swatch-todo { + background: #d4d4db; } -div.tiny_dashboard_widget.full-optimized div.inside.mobile div#optimization-chart { - width: 100%; - margin-top: -5px; - margin-bottom: 95px; +.tiny-widget-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; } -.ie8 div.tiny_dashboard_widget div.inside div.panda { +.tiny-widget .button-large { + min-height: 44px; + padding: 0 20px; + line-height: 42px; + font-weight: 600; +} + +.tiny-widget-link { + align-self: flex-start; + padding: 0; + border: 0; background: none; + color: var(--wp-admin-theme-color, #2271b1); + font-size: 14px; + line-height: 20px; + cursor: pointer; } -.ie8 div.tiny_dashboard_widget div.inside div.grass { - width: 100%; - background-image: url("../images/grass-ie8.png"); - background-repeat: no-repeat; - background-position: bottom; +.tiny-widget-link:hover, +.tiny-widget-link:focus { + text-decoration: underline; } -.ie8 div.tiny_dashboard_widget div.inside div#optimization-chart { - display: none; +#tinypng_dashboard_widget .tiny-widget-credits-heading { + margin: 0; + padding: 0; + font-size: 16px; + line-height: 24px; + font-weight: 600; +} + +.tiny-widget-offer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.tiny-widget-offer + .tiny-widget-offer { + padding-top: 16px; + border-top: 1px solid #ededf1; +} + +.tiny-widget-offer-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.tiny-widget-offer-text strong { + font-weight: 500; } -.ie8 div.tiny_dashboard_widget.no-images-uploaded div.inside div#ie8-compressed, -.ie8 div.tiny_dashboard_widget.not-optimized div.inside div#ie8-compressed, -.ie8 div.tiny_dashboard_widget.half-optimized div.inside div#ie8-compressed, -.ie8 div.tiny_dashboard_widget.full-optimized div.inside div#ie8-compressed { - display: block !important; - position: relative; - z-index: 1; +.tiny-widget-offer-text span { + color: #5b5b67; +} + +.tiny-widget-offer .button { + flex-shrink: 0; +} + +.tiny-widget-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 16px; + padding: 12px; + border-top: 1px solid #ededf1; + background: #f8f8fa; +} + +.tiny-widget-footer nav { + display: flex; + gap: 16px; + font-weight: 600; +} + +.tiny-widget-footer a { + text-decoration: none; +} + +.tiny-widget-credits-line { + font-weight: 500; +} + +.tiny-widget-credits-line-warning { + color: #b45309; + font-weight: 600; +} + +.tiny-widget-credits-line-error { + color: #dc2626; + font-weight: 600; +} + +@container (max-width: 420px) { + .tiny-widget-panda { + width: 80px; + height: 80px; + } + + .tiny-widget-actions .button:not(:only-child) { + width: 100%; + text-align: center; + } + + .tiny-widget-offer { + flex-direction: column; + align-items: stretch; + } + + .tiny-widget-offer .button { + text-align: center; + } } -#tinypng_dashboard_widget > button.handlediv { - margin-top: 0; +@container (max-width: 340px) { + .tiny-widget-panda { + display: none; + } } diff --git a/src/images/cloud.png b/src/images/cloud.png deleted file mode 100755 index ae57e01f..00000000 Binary files a/src/images/cloud.png and /dev/null differ diff --git a/src/images/grass-ie8.png b/src/images/grass-ie8.png deleted file mode 100644 index 3ac19f9a..00000000 Binary files a/src/images/grass-ie8.png and /dev/null differ diff --git a/src/images/grass.png b/src/images/grass.png deleted file mode 100755 index 71e7b9a8..00000000 Binary files a/src/images/grass.png and /dev/null differ diff --git a/src/images/panda-eating.png b/src/images/panda-eating.png deleted file mode 100755 index 6579fcb7..00000000 Binary files a/src/images/panda-eating.png and /dev/null differ diff --git a/src/images/panda-laying.png b/src/images/panda-laying.png deleted file mode 100755 index 8f341deb..00000000 Binary files a/src/images/panda-laying.png and /dev/null differ diff --git a/src/images/panda-waiting.png b/src/images/panda-waiting.png deleted file mode 100755 index f6ac18af..00000000 Binary files a/src/images/panda-waiting.png and /dev/null differ diff --git a/src/images/widget/panda-astronaut.png b/src/images/widget/panda-astronaut.png new file mode 100644 index 00000000..02c5f81e Binary files /dev/null and b/src/images/widget/panda-astronaut.png differ diff --git a/src/images/widget/panda-coffee.png b/src/images/widget/panda-coffee.png new file mode 100644 index 00000000..3197512d Binary files /dev/null and b/src/images/widget/panda-coffee.png differ diff --git a/src/images/widget/panda-duck.png b/src/images/widget/panda-duck.png new file mode 100644 index 00000000..0fa09a53 Binary files /dev/null and b/src/images/widget/panda-duck.png differ diff --git a/src/images/widget/panda-juggle.png b/src/images/widget/panda-juggle.png new file mode 100644 index 00000000..3d850bfe Binary files /dev/null and b/src/images/widget/panda-juggle.png differ diff --git a/src/images/widget/panda-rocket.png b/src/images/widget/panda-rocket.png new file mode 100644 index 00000000..76da09d2 Binary files /dev/null and b/src/images/widget/panda-rocket.png differ diff --git a/src/images/widget/panda-skate.png b/src/images/widget/panda-skate.png new file mode 100644 index 00000000..f2917005 Binary files /dev/null and b/src/images/widget/panda-skate.png differ diff --git a/src/images/widget/panda-unbox.png b/src/images/widget/panda-unbox.png new file mode 100644 index 00000000..25cfdb7a Binary files /dev/null and b/src/images/widget/panda-unbox.png differ diff --git a/src/js/bulk-optimization.js b/src/js/bulk-optimization.js index 264d3915..5da2a34e 100644 --- a/src/js/bulk-optimization.js +++ b/src/js/bulk-optimization.js @@ -188,12 +188,34 @@ }); } - function prepareBulkOptimization(items, running) { + function prepareBulkOptimization(items, running, startOnLoad) { window.allBulkOptimizationItems = items; updateProgressBar(0); if (running) { startBulkOptimization(items, true); } + startFromUrl(items, running, startOnLoad); + } + + // The dashboard widget opens this page with &start=1 and a nonce, which the + // server checks. Start right away, then drop both from the URL so a reload + // does not start again. + function startFromUrl(items, running, startOnLoad) { + const url = new URL(location.href); + if (url.searchParams.has('start')) { + url.searchParams.delete('start'); + url.searchParams.delete('_wpnonce'); + history.replaceState(null, '', url); + } + if (!startOnLoad || running) { + return; + } + + const start = document.getElementById('id-start'); + if (start && start.offsetParent !== null) { + jQuery('#hide-warning').trigger('click'); + startBulkOptimization(items); + } } function startBulkOptimization(items, running) { diff --git a/src/js/dashboard-widget.js b/src/js/dashboard-widget.js index 0bd39aa1..4e49b183 100644 --- a/src/js/dashboard-widget.js +++ b/src/js/dashboard-widget.js @@ -1,125 +1,40 @@ -(function() { - function generateDashboardWidget(element) { - element = jQuery(element); - var container = element.find('.inside'); - jQuery('.chart').addClass('hidden'); - // Adding a class to the widget element so that classes are only used in the stylesheet - jQuery('#tinypng_dashboard_widget').addClass('tiny_dashboard_widget'); - attachHandlers(container); - retrieveStats(container); +(function () { + const container = document.querySelector('#tinypng_dashboard_widget .inside'); + if (!container) { + return; } - function retrieveStats(container) { - jQuery.ajax({ - url: ajaxurl, - type: 'POST', - data: { - _nonce: tinyCompressDashboard.nonce, - action: 'tiny_get_optimization_statistics', - id: '#tinypng_dashboard_widget' - }, - success: function(data) { - if (data === 0) { - container.append('

An error occured.

'); - } else { - renderWidget(data); - } - }, - error: function() { - container.append('

An error occured.

'); + // The statistics scan the whole library, so the server renders the widget + // after the page has loaded and all state rules stay in PHP. + async function load() { + const loading = container.querySelector('.tiny-widget-loading'); + try { + const response = await fetch(window.ajaxurl, { + method: 'POST', + credentials: 'same-origin', + body: new URLSearchParams({ action: 'tiny_dashboard_widget', _nonce: loading.dataset.nonce }), + }); + if (!response.ok) { + throw new Error(response.statusText); } - }); - } - - function attachHandlers(container) { - setContainerClass(container); - jQuery(window).resize(function(){ - setContainerClass(container); - }); - jQuery('#tinypng_dashboard_widget .hndle').click(function() { - jQuery(this).siblings('.inside').removeClass('mobile'); - }); - } - - function setContainerClass(container) { - if (jQuery(container).width() < 400) { - jQuery(container).addClass('mobile'); - } else if (jQuery(container).width() < 490 && jQuery(container).width() >= 400) { - jQuery(container).addClass('tablet'); - jQuery(container).removeClass('mobile'); - } else if (jQuery(container).width() >= 490) { - jQuery(container).removeClass('tablet').removeClass('mobile'); - } - } - - function renderWidget(data) { - var stats = jQuery.parseJSON(data); - if (stats !== null) { - var savings = stats['display-percentage']; - var libraryOptimized = optimizedPercentage(stats); - renderContent(libraryOptimized, stats, savings); - renderChart(savings); - jQuery('#optimization-chart').show(); - } - jQuery('#widget-spinner').attr('class', 'hidden'); - } - - function renderPercentage(percentage) { - jQuery('#savings-percentage').find('span').html(percentage); - } - - function renderContent(percentage, stats, savingsPercentage) { - renderPercentage(savingsPercentage); - if ( 0 === stats['uploaded-images'] + stats['available-unoptimized-sizes'] ) { - jQuery('#tinypng_dashboard_widget').addClass('no-images-uploaded'); - } else if ( percentage === 0 ) { - jQuery('#tinypng_dashboard_widget').addClass('not-optimized'); - } else if ( percentage === 100 ) { - jQuery('#tinypng_dashboard_widget').addClass('full-optimized'); - } else { - jQuery('#uploaded-images').html( stats['uploaded-images'] ); - jQuery('#unoptimized-sizes').html( stats['available-unoptimized-sizes'] ); - jQuery('#tinypng_dashboard_widget').addClass('half-optimized'); + container.innerHTML = await response.text(); + } catch (error) { + loading.querySelector('.spinner').remove(); + loading.querySelector('p').hidden = false; } - jQuery('#ie8-compressed').find('span').html(savingsPercentage); - } - - function chartOptions(percentage) { - var chart = {}; - chart.size = 160; - chart.radius = chart.size / 2 * 0.9; - chart['main-radius'] = chart.radius * 0.88; - chart['circle-size'] = 2 * Math.PI * chart['main-radius']; - chart['dash-array-size'] = percentage / 100 * chart['circle-size']; - return chart; } - function optimizedPercentage(stats) { - if ( 0 !== stats['unoptimized-library-size'] ) { - return Math.round((stats['optimized-image-sizes'] / (stats['optimized-image-sizes'] + stats['available-unoptimized-sizes']) * 100), 0); - } else { - return 0; + // Get more credits is a second view in the same HTML. Nothing is stored, so + // a reload shows the main view again. + container.addEventListener('click', function (event) { + const action = event.target.closest('[data-tiny-action]'); + if (!action) { + return; } - } - - function renderChart(savingsPercentage) { - var chart = chartOptions(savingsPercentage); - jQuery('#optimization-chart svg circle.main').css('stroke-dasharray', chart['dash-array-size'] + ' ' + chart['circle-size']); - var style = - ' @keyframes shwoosh {' + - ' from { stroke-dasharray: 0 ' + chart['circle-size'] + '}' + - ' to { stroke-dasharray:' + chart['dash-array-size'] + ' ' + chart['circle-size'] + '}}'; + const showCredits = action.dataset.tinyAction === 'more-credits'; + container.querySelector('.tiny-widget-main').hidden = showCredits; + container.querySelector('.tiny-widget-credits').hidden = !showCredits; + }); - // JQuery bug where you cannot append to style tag https://bugs.jquery.com/ticket/9832 - try { - jQuery('#tinypng_dashboard_widget .inside style').append(style); - } catch(err) { - - } - } - - // Check if widget is loaded - if (jQuery('#tinypng_dashboard_widget').length) { - generateDashboardWidget('#tinypng_dashboard_widget'); - } -}).call(); + load(); +})(); diff --git a/src/views/bulk-optimization.php b/src/views/bulk-optimization.php index 3ba98ec4..025dc298 100644 --- a/src/views/bulk-optimization.php +++ b/src/views/bulk-optimization.php @@ -327,7 +327,7 @@ ?> diff --git a/src/views/dashboard-widget-loading.php b/src/views/dashboard-widget-loading.php new file mode 100644 index 00000000..eed8daab --- /dev/null +++ b/src/views/dashboard-widget-loading.php @@ -0,0 +1,12 @@ + + +
+ + +
diff --git a/src/views/dashboard-widget.php b/src/views/dashboard-widget.php index 5cc866b6..13ffc077 100644 --- a/src/views/dashboard-widget.php +++ b/src/views/dashboard-widget.php @@ -3,102 +3,319 @@ /** * Dashboard Widget * - * @var array $admin_colors - * + * @var array $widget See Tiny_Dashboard::get_widget_data(). */ -$link = '' . esc_html__( 'bulk optimization', 'tiny-compress-images' ) . ''; -?> - +$state = $widget['state']; -
-
-
-
-
-
-

-
-
-

- friendly_user_name() ) ); - echo ' '; - printf( - wp_kses( - /* translators: %s: bulk optimization page */ - __( 'If you like you can to optimize your whole library in one go with the %s page.', 'tiny-compress-images' ), - array( - 'a' => array( - 'href' => array(), - ), - ) - ), - wp_kses( $link, array( 'a' => array( 'href' => array() ) ) ) - ); - ?> -

-
-
-

- friendly_user_name() ) ); - echo ' '; - /* translators: %s: number of optimizable sizes and number of uploaded images */ - printf( esc_html__( 'With your current settings you can still optimize %1$s image sizes from your %2$s uploaded JPEG, PNG, and WebP images.', 'tiny-compress-images' ), '', '' ); - echo ' '; - printf( - wp_kses( - /* translators: %s: bulk optimization link */ - __( 'Start the %s to optimize the remainder of your library.', 'tiny-compress-images' ), - array( - 'a' => array( - 'href' => array(), - ), - ) - ), - wp_kses( $link, array( 'a' => array( 'href' => array() ) ) ) +$bulk_url = admin_url( 'upload.php?page=tiny-bulk-optimization' ); +$start_url = wp_nonce_url( $bulk_url . '&start=1', 'tiny-bulk-start' ); +$settings_url = admin_url( 'options-general.php?page=tinify' ); +$upload_url = admin_url( 'media-new.php' ); +$credits_url = 'https://tinify.com/checkout/credits?utm_source=wordpress-plugin&utm_medium=referral&utm_campaign=upgrade&utm_content=dashboard-widget-credits'; +$api_url = 'https://tinify.com/checkout/api?utm_source=wordpress-plugin&utm_medium=referral&utm_campaign=upgrade&utm_content=dashboard-widget-api'; +$strong = array( 'strong' => array() ); +$can_manage = current_user_can( 'manage_options' ); + +$badges = array( + 'not_connected' => array( 'red', __( 'Not connected', 'tiny-compress-images' ) ), + 'pending' => array( 'amber', __( 'Waiting for activation', 'tiny-compress-images' ) ), + 'empty' => array( 'amber', __( 'Nothing to compress', 'tiny-compress-images' ) ), + 'ready' => array( 'green', __( 'Ready to compress', 'tiny-compress-images' ) ), + 'compressing' => array( 'green', __( 'Compressing', 'tiny-compress-images' ) ), + 'done' => array( 'green', __( 'All done', 'tiny-compress-images' ) ), + 'credits_short' => array( 'amber', __( 'Not enough credits', 'tiny-compress-images' ) ), + 'out_of_credits' => array( 'red', __( 'Out of credits', 'tiny-compress-images' ) ), +); + +$pandas = array( + 'not_connected' => 'panda-juggle', + 'pending' => 'panda-unbox', + 'empty' => 'panda-astronaut', + 'ready' => 'panda-skate', + 'compressing' => 'panda-rocket', + 'done' => 'panda-duck', + 'credits_short' => 'panda-skate', + 'out_of_credits' => 'panda-coffee', +); + +list( $badge_colour, $badge_text ) = $badges[ $state ]; + +$has_library_bar = ! in_array( $state, array( 'pending', 'empty' ), true ); +$has_credits_view = in_array( $state, array( 'credits_short', 'out_of_credits' ), true ); +$has_credits_line = null !== $widget['remaining_credits'] && + ! in_array( $state, array( 'not_connected', 'pending' ), true ); + +$seconds_left_text = ''; +if ( null !== $widget['seconds_left'] ) { + if ( $widget['seconds_left'] < 60 ) { + $seconds_left_text = __( 'Less than a minute left.', 'tiny-compress-images' ); + } elseif ( $widget['seconds_left'] < 90 * 60 ) { + $minutes = intval( round( $widget['seconds_left'] / 60 ) ); + $seconds_left_text = sprintf( + /* translators: %s: number of minutes */ + _n( 'About %s minute left.', 'About %s minutes left.', $minutes, 'tiny-compress-images' ), + number_format_i18n( $minutes ) ); - ?> -

-
-
-

- friendly_user_name() ) ); - echo ' '; - require __DIR__ . '/request-review.php'; - ?> -

-
-
-

- saved %s of your media library size.', 'tiny-compress-images' ), - array( - 'span' => array(), - 'strong' => array(), - ) - ), - '%' + } else { + $hours = intval( round( $widget['seconds_left'] / 3600 ) ); + $seconds_left_text = sprintf( + /* translators: %s: number of hours */ + _n( 'About %s hour left.', 'About %s hours left.', $hours, 'tiny-compress-images' ), + number_format_i18n( $hours ) ); - ?> -

-
+ } +} +?> - +
+
+
+
+ + +

+

+ +

+ +

+ +

+ +

+ + +
+ % + +
+ +

+ +

+ +

+ +

+ +

+ + +
+ +
+ +
+ +
+
+
+
+
+ + + %s compressed', '%s compressed', $widget['images_optimized'], 'tiny-compress-images' ), $strong ), + esc_html( number_format_i18n( $widget['images_optimized'] ) ) + ); + ?> + + + + %s saved', 'tiny-compress-images' ), $strong ), + esc_html( size_format( $widget['bytes_saved'], 1 ) ) + ); + ?> + + + %s to go', '%s to go', $widget['images_remaining'], 'tiny-compress-images' ); + } else { + /* translators: %s: number of images still to compress */ + $to_compress = _n( '%s to compress', '%s to compress', $widget['images_remaining'], 'tiny-compress-images' ); + } + printf( + wp_kses( $to_compress, $strong ), + esc_html( number_format_i18n( $widget['images_remaining'] ) ) + ); + ?> + + +
+
+ + + +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + +
+

+ +

+ +
+ +
+ +
+ + = 1 ) { ?> + + + + +
+

+ +

+ +
+ +
+

+ +
+
+ + + + + + +
diff --git a/test/integration/dashboardwidget.spec.ts b/test/integration/dashboardwidget.spec.ts index 50836014..6850042d 100644 --- a/test/integration/dashboardwidget.spec.ts +++ b/test/integration/dashboardwidget.spec.ts @@ -1,62 +1,133 @@ import { Page, expect, test } from '@playwright/test'; -import { clearMediaLibrary, setAPIKey, setCompressionTiming, uploadMedia, enableCompressionSizes } from './utils'; +import { clearMediaLibrary, setAPIKey, setCompressionTiming, uploadImages, uploadMedia, enableCompressionSizes, setConversionSettings } from './utils'; test.describe.configure({ mode: 'serial' }); let page: Page; +const widget = () => page.locator('#tinypng_dashboard_widget'); + +// The settings page fetches the account details over ajax after saving, +// so wait for that before the dashboard reads them. +async function setAccount(key: string) { + await setAPIKey(page, key); + await page.waitForURL(/settings-updated=true/); + await page.waitForLoadState('networkidle'); +} + test.describe('dashboardwidget', () => { test.beforeAll(async ({ browser }) => { - page = await browser.newPage(); + page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); await setAPIKey(page, ''); + await setCompressionTiming(page, 'auto'); await enableCompressionSizes(page, [], true); // enable all sizes + await setConversionSettings(page, { convert: false }); }); test.beforeEach(async () => { await clearMediaLibrary(page); + await page.context().clearCookies({ name: /^tiny_test_/ }); }); - test('show widget without images', async () => { + test('not connected without api key', async () => { + await setAPIKey(page, ''); + await uploadMedia(page, 'input-example.png'); + await page.goto('/wp-admin/index.php'); - await expect(page.getByText('You do not seem to have uploaded any JPEG, PNG or WebP images yet.')).toBeVisible(); + + await expect(widget().getByText('Not connected')).toBeVisible(); + await expect(widget().getByText('0%')).toBeVisible(); + await expect(widget().getByText('1 to compress')).toBeVisible(); + await expect(widget().getByRole('link', { name: 'Connect Tinify' })).toHaveAttribute('href', /page=tinify/); }); - test('show widget without optimized images', async () => { - // It won't compress images without an API Key - await setAPIKey(page, ''); + test('no images yet', async () => { + await setAPIKey(page, 'JPG123'); await setCompressionTiming(page, 'auto'); - await uploadMedia(page, 'input-example.png'); + await page.goto('/wp-admin/index.php'); + + await expect(widget().getByText('Upload your first image')).toBeVisible(); + await expect(widget().getByText('Tinify compresses them automatically')).toBeVisible(); + await expect(widget().getByRole('link', { name: 'Upload images' })).toHaveAttribute('href', /media-new\.php/); + }); + + test('no images yet without auto compress', async () => { + await setAPIKey(page, 'JPG123'); + await setCompressionTiming(page, 'manual'); await page.goto('/wp-admin/index.php'); - await expect( - page.getByText(`Hi Admin, you haven't compressed any images in your media library. If you like you can to optimize your whole library in one go with the bulk optimization page.`) - ).toBeVisible(); + + await expect(widget().getByText('Upload your first image')).toBeVisible(); + await expect(widget().getByText('then compress them with the Bulk Optimizer')).toBeVisible(); + await expect(widget().getByText('automatically')).not.toBeVisible(); }); - test('show widget with some images optimized', async () => { - await setAPIKey(page, ''); + test('ready to compress', async () => { + await setAccount('JPG123'); + await setCompressionTiming(page, 'manual'); + await uploadImages(page, 'input-example.jpg'); - await setCompressionTiming(page, 'auto'); - - await uploadMedia(page, 'input-example.jpg'); + await page.goto('/wp-admin/index.php'); - await setAPIKey(page, 'JPG123'); + await expect(widget().getByText('Ready to compress')).toBeVisible(); + await expect(widget().getByRole('link', { name: 'Compress 1 image' })).toHaveAttribute('href', /page=tiny-bulk-optimization&start=1&_wpnonce=\w+$/); + }); + test('all done', async () => { + await setAPIKey(page, 'JPG123'); + await setCompressionTiming(page, 'auto'); await uploadMedia(page, 'input-example.jpg'); await page.goto('/wp-admin/index.php'); - await expect(page.getByText('Admin, you are doing good. With your current settings you can still optimize')).toBeVisible(); + await expect(widget().getByText('All done')).toBeVisible(); + await expect(widget().getByText('100%')).toBeVisible(); + await expect(widget().getByText('1 compressed')).toBeVisible(); }); - test('show widget with all images optimized', async () => { + test('compress link starts the bulk optimization', async () => { + await setAPIKey(page, 'JPG123'); + await setCompressionTiming(page, 'manual'); + await uploadImages(page, 'input-example.jpg', 1, { generateSizes: false }); + await page.goto('/wp-admin/index.php'); + await widget().getByRole('link', { name: 'Compress 1 image' }).click(); - await uploadMedia(page, 'input-example.jpg'); + await expect(page).toHaveURL(/page=tiny-bulk-optimization$/); + await expect(page.locator('#compression-progress-bar')).toHaveText('1 / 1 (100%)', { timeout: 15000 }); + }); + + test('start flag without a nonce does not start the bulk optimization', async () => { + await setAPIKey(page, 'JPG123'); + await setCompressionTiming(page, 'manual'); + await uploadImages(page, 'input-example.jpg', 1, { generateSizes: false }); + + await page.goto('/wp-admin/upload.php?page=tiny-bulk-optimization&start=1'); + + await expect(page).toHaveURL(/page=tiny-bulk-optimization$/); + await expect(page.locator('#id-start')).toBeVisible(); + await expect(page.locator('#compression-progress-bar')).toHaveText('0 / 1 (0%)'); + }); + + test('upgrade shows how to get more credits', async () => { + await setAccount('INSUFFICIENTCREDITS123'); + await setCompressionTiming(page, 'auto'); + await uploadImages(page, 'input-example.jpg'); await page.goto('/wp-admin/index.php'); - await expect(page.getByText('Admin, this is great! Your entire library is optimized!')).toBeVisible(); + await expect(widget().getByText('Not enough credits')).toBeVisible(); + await widget().getByRole('button', { name: 'Upgrade' }).click(); + + await expect(widget().getByText('Get more credits')).toBeVisible(); + await expect(widget().getByText('Not enough credits')).not.toBeVisible(); + await expect(widget().getByRole('link', { name: 'Buy a credit bundle' })).toHaveAttribute('href', 'https://tinify.com/checkout/credits?utm_source=wordpress-plugin&utm_medium=referral&utm_campaign=upgrade&utm_content=dashboard-widget-credits'); + await expect(widget().getByRole('link', { name: 'Switch to pay per compression' })).toHaveAttribute('href', 'https://tinify.com/checkout/api?utm_source=wordpress-plugin&utm_medium=referral&utm_campaign=upgrade&utm_content=dashboard-widget-api'); + + await widget().getByRole('button', { name: 'Back' }).click(); + + await expect(widget().getByText('Not enough credits')).toBeVisible(); + await expect(widget().getByText('Get more credits')).not.toBeVisible(); }); }); diff --git a/test/unit/TinySettingsAdminTest.php b/test/unit/TinySettingsAdminTest.php index 37a7c1f0..f481f5c4 100644 --- a/test/unit/TinySettingsAdminTest.php +++ b/test/unit/TinySettingsAdminTest.php @@ -11,6 +11,47 @@ public function set_up() { $this->subject->admin_init(); } + /** + * helper to mark the API key as pending and answer its status check + * + * @param bool $ok whether the API says the key works + * @return Tiny_Compress|\PHPUnit\Framework\MockObject\MockObject + */ + private function pending_key( $ok ) { + $this->wp->addMethod( 'delete_option' ); + $this->wp->addOption( 'tinypng_api_key_pending', true ); + + $compressor = $this->createMock( 'Tiny_Compress' ); + $compressor->method( 'get_status' )->willReturn( (object) array( 'ok' => $ok ) ); + $this->subject->set_compressor( $compressor ); + return $compressor; + } + + public function test_refresh_api_key_pending_does_not_ask_the_api_when_not_pending() { + $compressor = $this->pending_key( true ); + $this->wp->addOption( 'tinypng_api_key_pending', false ); + $compressor->expects( $this->never() )->method( 'get_status' ); + + $this->assertFalse( $this->subject->refresh_api_key_pending() ); + } + + public function test_refresh_api_key_pending_clears_the_flag_once_the_key_works() { + $this->pending_key( true ); + + $this->assertFalse( $this->subject->refresh_api_key_pending() ); + $this->assertEquals( + array( array( 'tinypng_api_key_pending' ) ), + $this->wp->getCalls( 'delete_option' ) + ); + } + + public function test_refresh_api_key_pending_stays_pending_while_the_key_does_not_work() { + $this->pending_key( false ); + + $this->assertTrue( $this->subject->refresh_api_key_pending() ); + $this->assertEmpty( $this->wp->getCalls( 'delete_option' ) ); + } + public function test_admin_init_should_register_keys() { $this->assertEquals(array( array( 'tinify', 'tinypng_api_key' ), diff --git a/test/unit/Tiny_Dashboard_Test.php b/test/unit/Tiny_Dashboard_Test.php new file mode 100644 index 00000000..8f267df6 --- /dev/null +++ b/test/unit/Tiny_Dashboard_Test.php @@ -0,0 +1,368 @@ +settings = $this->createMock(Tiny_Settings::class); + $this->background_optimizer = $this->createMock(Tiny_Background_Optimize::class); + } + + /** + * helper to build optimization stats + * + * @param int $uploaded number of uploaded images + * @param int $remaining number of images remaining + * @param integer $unoptimized_size + * @param integer $optimized_size + * @param int $credit_use estimated credits the remaining images need + * @return array optimization stats + */ + private function stats($uploaded, $remaining, $unoptimized_size = 0, $optimized_size = 0, $credit_use = 0) + { + return array( + 'uploaded-images' => $uploaded, + 'optimized-image-sizes' => 0, + 'available-unoptimized-sizes' => 0, + 'optimized-library-size' => $optimized_size, + 'unoptimized-library-size' => $unoptimized_size, + 'estimated_credit_use' => $credit_use, + 'available-for-optimization' => array_fill(0, $remaining, array('ID' => 1, 'post_title' => 'image')), + 'display-percentage' => 0, + ); + } + + /** + * helper to connect an account + * + * @param int|false $remaining_credits credits left, false when unknown + * @param bool $free whether the account is on the free plan + */ + private function connect($remaining_credits = false, $free = true) + { + $this->settings->method('has_api_key')->willReturn(true); + $this->settings->method('is_on_free_plan')->willReturn($free); + $this->settings->method('get_remaining_credits')->willReturn($remaining_credits); + } + + private function widget_data($stats) + { + $dashboard = new Tiny_Dashboard($this->settings, $this->background_optimizer); + return $dashboard->get_widget_data($stats); + } + + public function test_is_not_connected_without_api_key() + { + $this->settings->method('has_api_key')->willReturn(false); + + $data = $this->widget_data($this->stats(1250, 330)); + + $this->assertSame('not_connected', $data['state']); + $this->assertSame(920, $data['images_optimized']); + $this->assertSame(1250, $data['images_total']); + $this->assertSame(330, $data['images_remaining']); + $this->assertSame(73, $data['percentage']); + } + + public function test_is_pending_while_api_key_waits_for_activation() + { + $this->connect(); + $this->settings->method('refresh_api_key_pending')->willReturn(true); + $this->settings->method('get_email_address')->willReturn('test@example.com'); + + $data = $this->widget_data($this->stats(10, 10)); + + $this->assertSame('pending', $data['state']); + $this->assertSame('test@example.com', $data['email_address']); + } + + public function test_is_empty_when_there_are_no_images() + { + $this->connect(); + + $data = $this->widget_data($this->stats(0, 0)); + + $this->assertSame('empty', $data['state']); + $this->assertSame(0, $data['percentage']); + } + + public function test_is_compressing_while_a_run_is_going() + { + $this->connect(); + $this->background_optimizer->method('is_running')->willReturn(true); + + $data = $this->widget_data($this->stats(10, 4)); + + $this->assertSame('compressing', $data['state']); + } + + public function test_is_done_when_no_images_remain() + { + $this->connect(); + + $data = $this->widget_data($this->stats(1250, 0, 5000, 3000)); + + $this->assertSame('done', $data['state']); + $this->assertSame(100, $data['percentage']); + $this->assertSame(2000, $data['bytes_saved']); + } + + public function test_auto_compress_is_on_when_the_setting_is() + { + $this->connect(); + $this->settings->method('auto_compress_enabled')->willReturn(true); + + $data = $this->widget_data($this->stats(10, 0)); + + $this->assertTrue($data['auto_compress']); + } + + public function test_auto_compress_is_off_when_the_setting_is() + { + $this->connect(); + $this->settings->method('auto_compress_enabled')->willReturn(false); + + $data = $this->widget_data($this->stats(10, 0)); + + $this->assertFalse($data['auto_compress']); + } + + public function test_is_out_of_credits_when_none_are_left() + { + $this->connect(0); + + $data = $this->widget_data($this->stats(10, 4, 0, 0, 12)); + + $this->assertSame('out_of_credits', $data['state']); + } + + public function test_is_short_of_credits_when_they_do_not_cover_the_remaining_images() + { + $this->connect(10); + + $data = $this->widget_data($this->stats(10, 4, 0, 0, 12)); + + $this->assertSame('credits_short', $data['state']); + $this->assertSame(12, $data['estimated_credit_use']); + } + + public function test_is_ready_when_credits_cover_the_remaining_images() + { + $this->connect(12); + + $data = $this->widget_data($this->stats(10, 4, 0, 0, 12)); + + $this->assertSame('ready', $data['state']); + } + + public function test_is_ready_on_paid_plan_whatever_the_credits() + { + $this->connect(0, false); + + $data = $this->widget_data($this->stats(10, 4, 0, 0, 12)); + + $this->assertSame('ready', $data['state']); + } + + public function test_pending_beats_empty() + { + $this->connect(); + $this->settings->method('refresh_api_key_pending')->willReturn(true); + + $data = $this->widget_data($this->stats(0, 0)); + + $this->assertSame('pending', $data['state']); + } + + public function test_compressing_beats_done() + { + $this->connect(); + $this->background_optimizer->method('is_running')->willReturn(true); + + $data = $this->widget_data($this->stats(10, 0)); + + $this->assertSame('compressing', $data['state']); + } + + public function test_compressing_beats_out_of_credits() + { + $this->connect(0); + $this->background_optimizer->method('is_running')->willReturn(true); + + $data = $this->widget_data($this->stats(10, 4, 0, 0, 12)); + + $this->assertSame('compressing', $data['state']); + } + + public function test_done_beats_out_of_credits() + { + $this->connect(0); + + $data = $this->widget_data($this->stats(10, 0)); + + $this->assertSame('done', $data['state']); + } + + public function test_does_not_show_100_percent_while_images_remain() + { + $this->connect(); + + $data = $this->widget_data($this->stats(1000, 1)); + + $this->assertSame(99, $data['percentage']); + } + + public function test_bytes_saved_is_never_negative() + { + $this->connect(); + + $data = $this->widget_data($this->stats(10, 3, 3000, 5000)); + + $this->assertSame(0, $data['bytes_saved']); + } + + public function test_shows_remaining_credits_on_free_plan() + { + $this->connect('210'); + + $data = $this->widget_data($this->stats(10, 1)); + + $this->assertSame(210, $data['remaining_credits']); + } + + public function test_shows_remaining_credits_above_the_monthly_free_credits() + { + $this->connect(1200); + + $data = $this->widget_data($this->stats(10, 1)); + + $this->assertSame(1200, $data['remaining_credits']); + } + + public function test_shows_zero_remaining_credits() + { + $this->connect(0); + + $data = $this->widget_data($this->stats(10, 1)); + + $this->assertSame(0, $data['remaining_credits']); + } + + public function test_hides_remaining_credits_on_paid_plan() + { + $this->connect(210, false); + + $data = $this->widget_data($this->stats(10, 1)); + + $this->assertNull($data['remaining_credits']); + } + + public function test_hides_remaining_credits_when_unknown() + { + $this->connect(false); + + $data = $this->widget_data($this->stats(10, 1)); + + $this->assertNull($data['remaining_credits']); + } + + public function test_images_affordable_is_what_the_credits_cover_at_the_average_cost() + { + $this->connect(10); + + // 15 images need 30 credits, 2 each, so 10 credits cover 5 images. + $data = $this->widget_data($this->stats(20, 15, 0, 0, 30)); + + $this->assertSame(5, $data['images_affordable']); + } + + public function test_images_affordable_is_zero_without_estimated_credit_use() + { + $this->connect(10); + + $data = $this->widget_data($this->stats(20, 15, 0, 0, 0)); + + $this->assertSame(0, $data['images_affordable']); + } + + public function test_images_affordable_is_zero_without_remaining_images() + { + $this->connect(10); + + $data = $this->widget_data($this->stats(20, 0, 0, 0, 0)); + + $this->assertSame(0, $data['images_affordable']); + } + + public function test_images_affordable_is_zero_without_limited_credits() + { + $this->connect(10, false); + + $data = $this->widget_data($this->stats(20, 15, 0, 0, 30)); + + $this->assertSame(0, $data['images_affordable']); + } + + public function test_seconds_left_spreads_compressions_over_the_workers() + { + $this->connect(); + $this->background_optimizer->method('is_running')->willReturn(true); + + $data = $this->widget_data($this->stats(30, 20, 0, 0, 1200)); + + $this->assertSame(240, $data['seconds_left']); + } + + public function test_widget_request_refuses_an_invalid_nonce_with_a_403() + { + $this->wp->stub('check_ajax_referer', function () { + return false; + }); + $this->wp->stub('current_user_can', function () { + return true; + }); + + $this->assert_widget_request_refused_with_a_403(); + } + + public function test_widget_request_refuses_a_user_without_permission_with_a_403() + { + $this->wp->stub('current_user_can', function () { + return false; + }); + + $this->assert_widget_request_refused_with_a_403(); + } + + private function assert_widget_request_refused_with_a_403() + { + $this->wp->addMethod('status_header'); + $this->wp->addMethod('wp_die'); + $this->wp->stub('wp_die', function () { + throw new Exception('wp_die'); + }); + + $dashboard = new Tiny_Dashboard($this->settings, $this->background_optimizer); + try { + $dashboard->ajax_dashboard_widget(); + } catch (Exception $e) { + // wp_die() ends the request. + } + + $this->assertEquals(array(array(403)), $this->wp->getCalls('status_header')); + $this->assertEquals(array(array(-1, 403)), $this->wp->getCalls('wp_die')); + } +} diff --git a/tiny-compress-images.php b/tiny-compress-images.php index 578bc8bb..168ff18d 100644 --- a/tiny-compress-images.php +++ b/tiny-compress-images.php @@ -28,6 +28,7 @@ require dirname( __FILE__ ) . '/src/class-tiny-cli.php'; require dirname( __FILE__ ) . '/src/class-tiny-conversion.php'; require dirname( __FILE__ ) . '/src/class-tiny-picture.php'; +require dirname( __FILE__ ) . '/src/class-tiny-dashboard.php'; require dirname( __FILE__ ) . '/src/class-tiny-apache-rewrite.php'; require dirname( __FILE__ ) . '/src/compatibility/wpml/class-tiny-wpml.php'; require dirname( __FILE__ ) . '/src/compatibility/as3cf/class-tiny-as3cf.php';