site-health.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. /**
  2. * Interactions used by the Site Health modules in WordPress.
  3. *
  4. * @output wp-admin/js/site-health.js
  5. */
  6. /* global ajaxurl, ClipboardJS, SiteHealth, wp */
  7. jQuery( function( $ ) {
  8. var __ = wp.i18n.__,
  9. _n = wp.i18n._n,
  10. sprintf = wp.i18n.sprintf,
  11. clipboard = new ClipboardJS( '.site-health-copy-buttons .copy-button' ),
  12. isStatusTab = $( '.health-check-body.health-check-status-tab' ).length,
  13. isDebugTab = $( '.health-check-body.health-check-debug-tab' ).length,
  14. pathsSizesSection = $( '#health-check-accordion-block-wp-paths-sizes' ),
  15. successTimeout;
  16. // Debug information copy section.
  17. clipboard.on( 'success', function( e ) {
  18. var triggerElement = $( e.trigger ),
  19. successElement = $( '.success', triggerElement.closest( 'div' ) );
  20. // Clear the selection and move focus back to the trigger.
  21. e.clearSelection();
  22. // Handle ClipboardJS focus bug, see https://github.com/zenorocha/clipboard.js/issues/680
  23. triggerElement.trigger( 'focus' );
  24. // Show success visual feedback.
  25. clearTimeout( successTimeout );
  26. successElement.removeClass( 'hidden' );
  27. // Hide success visual feedback after 3 seconds since last success.
  28. successTimeout = setTimeout( function() {
  29. successElement.addClass( 'hidden' );
  30. // Remove the visually hidden textarea so that it isn't perceived by assistive technologies.
  31. if ( clipboard.clipboardAction.fakeElem && clipboard.clipboardAction.removeFake ) {
  32. clipboard.clipboardAction.removeFake();
  33. }
  34. }, 3000 );
  35. // Handle success audible feedback.
  36. wp.a11y.speak( __( 'Site information has been copied to your clipboard.' ) );
  37. } );
  38. // Accordion handling in various areas.
  39. $( '.health-check-accordion' ).on( 'click', '.health-check-accordion-trigger', function() {
  40. var isExpanded = ( 'true' === $( this ).attr( 'aria-expanded' ) );
  41. if ( isExpanded ) {
  42. $( this ).attr( 'aria-expanded', 'false' );
  43. $( '#' + $( this ).attr( 'aria-controls' ) ).attr( 'hidden', true );
  44. } else {
  45. $( this ).attr( 'aria-expanded', 'true' );
  46. $( '#' + $( this ).attr( 'aria-controls' ) ).attr( 'hidden', false );
  47. }
  48. } );
  49. // Site Health test handling.
  50. $( '.site-health-view-passed' ).on( 'click', function() {
  51. var goodIssuesWrapper = $( '#health-check-issues-good' );
  52. goodIssuesWrapper.toggleClass( 'hidden' );
  53. $( this ).attr( 'aria-expanded', ! goodIssuesWrapper.hasClass( 'hidden' ) );
  54. } );
  55. /**
  56. * Validates the Site Health test result format.
  57. *
  58. * @since 5.6.0
  59. *
  60. * @param {Object} issue
  61. *
  62. * @return {boolean}
  63. */
  64. function validateIssueData( issue ) {
  65. // Expected minimum format of a valid SiteHealth test response.
  66. var minimumExpected = {
  67. test: 'string',
  68. label: 'string',
  69. description: 'string'
  70. },
  71. passed = true,
  72. key, value, subKey, subValue;
  73. // If the issue passed is not an object, return a `false` state early.
  74. if ( 'object' !== typeof( issue ) ) {
  75. return false;
  76. }
  77. // Loop over expected data and match the data types.
  78. for ( key in minimumExpected ) {
  79. value = minimumExpected[ key ];
  80. if ( 'object' === typeof( value ) ) {
  81. for ( subKey in value ) {
  82. subValue = value[ subKey ];
  83. if ( 'undefined' === typeof( issue[ key ] ) ||
  84. 'undefined' === typeof( issue[ key ][ subKey ] ) ||
  85. subValue !== typeof( issue[ key ][ subKey ] )
  86. ) {
  87. passed = false;
  88. }
  89. }
  90. } else {
  91. if ( 'undefined' === typeof( issue[ key ] ) ||
  92. value !== typeof( issue[ key ] )
  93. ) {
  94. passed = false;
  95. }
  96. }
  97. }
  98. return passed;
  99. }
  100. /**
  101. * Appends a new issue to the issue list.
  102. *
  103. * @since 5.2.0
  104. *
  105. * @param {Object} issue The issue data.
  106. */
  107. function appendIssue( issue ) {
  108. var template = wp.template( 'health-check-issue' ),
  109. issueWrapper = $( '#health-check-issues-' + issue.status ),
  110. heading,
  111. count;
  112. /*
  113. * Validate the issue data format before using it.
  114. * If the output is invalid, discard it.
  115. */
  116. if ( ! validateIssueData( issue ) ) {
  117. return false;
  118. }
  119. SiteHealth.site_status.issues[ issue.status ]++;
  120. count = SiteHealth.site_status.issues[ issue.status ];
  121. // If no test name is supplied, append a placeholder for markup references.
  122. if ( typeof issue.test === 'undefined' ) {
  123. issue.test = issue.status + count;
  124. }
  125. if ( 'critical' === issue.status ) {
  126. heading = sprintf(
  127. _n( '%s critical issue', '%s critical issues', count ),
  128. '<span class="issue-count">' + count + '</span>'
  129. );
  130. } else if ( 'recommended' === issue.status ) {
  131. heading = sprintf(
  132. _n( '%s recommended improvement', '%s recommended improvements', count ),
  133. '<span class="issue-count">' + count + '</span>'
  134. );
  135. } else if ( 'good' === issue.status ) {
  136. heading = sprintf(
  137. _n( '%s item with no issues detected', '%s items with no issues detected', count ),
  138. '<span class="issue-count">' + count + '</span>'
  139. );
  140. }
  141. if ( heading ) {
  142. $( '.site-health-issue-count-title', issueWrapper ).html( heading );
  143. }
  144. $( '.issues', '#health-check-issues-' + issue.status ).append( template( issue ) );
  145. }
  146. /**
  147. * Updates site health status indicator as asynchronous tests are run and returned.
  148. *
  149. * @since 5.2.0
  150. */
  151. function recalculateProgression() {
  152. var r, c, pct;
  153. var $progress = $( '.site-health-progress' );
  154. var $wrapper = $progress.closest( '.site-health-progress-wrapper' );
  155. var $progressLabel = $( '.site-health-progress-label', $wrapper );
  156. var $circle = $( '.site-health-progress svg #bar' );
  157. var totalTests = parseInt( SiteHealth.site_status.issues.good, 0 ) +
  158. parseInt( SiteHealth.site_status.issues.recommended, 0 ) +
  159. ( parseInt( SiteHealth.site_status.issues.critical, 0 ) * 1.5 );
  160. var failedTests = ( parseInt( SiteHealth.site_status.issues.recommended, 0 ) * 0.5 ) +
  161. ( parseInt( SiteHealth.site_status.issues.critical, 0 ) * 1.5 );
  162. var val = 100 - Math.ceil( ( failedTests / totalTests ) * 100 );
  163. if ( 0 === totalTests ) {
  164. $progress.addClass( 'hidden' );
  165. return;
  166. }
  167. $wrapper.removeClass( 'loading' );
  168. r = $circle.attr( 'r' );
  169. c = Math.PI * ( r * 2 );
  170. if ( 0 > val ) {
  171. val = 0;
  172. }
  173. if ( 100 < val ) {
  174. val = 100;
  175. }
  176. pct = ( ( 100 - val ) / 100 ) * c + 'px';
  177. $circle.css( { strokeDashoffset: pct } );
  178. if ( 1 > parseInt( SiteHealth.site_status.issues.critical, 0 ) ) {
  179. $( '#health-check-issues-critical' ).addClass( 'hidden' );
  180. }
  181. if ( 1 > parseInt( SiteHealth.site_status.issues.recommended, 0 ) ) {
  182. $( '#health-check-issues-recommended' ).addClass( 'hidden' );
  183. }
  184. if ( 80 <= val && 0 === parseInt( SiteHealth.site_status.issues.critical, 0 ) ) {
  185. $wrapper.addClass( 'green' ).removeClass( 'orange' );
  186. $progressLabel.text( __( 'Good' ) );
  187. wp.a11y.speak( __( 'All site health tests have finished running. Your site is looking good, and the results are now available on the page.' ) );
  188. } else {
  189. $wrapper.addClass( 'orange' ).removeClass( 'green' );
  190. $progressLabel.text( __( 'Should be improved' ) );
  191. wp.a11y.speak( __( 'All site health tests have finished running. There are items that should be addressed, and the results are now available on the page.' ) );
  192. }
  193. if ( isStatusTab ) {
  194. $.post(
  195. ajaxurl,
  196. {
  197. 'action': 'health-check-site-status-result',
  198. '_wpnonce': SiteHealth.nonce.site_status_result,
  199. 'counts': SiteHealth.site_status.issues
  200. }
  201. );
  202. if ( 100 === val ) {
  203. $( '.site-status-all-clear' ).removeClass( 'hide' );
  204. $( '.site-status-has-issues' ).addClass( 'hide' );
  205. }
  206. }
  207. }
  208. /**
  209. * Queues the next asynchronous test when we're ready to run it.
  210. *
  211. * @since 5.2.0
  212. */
  213. function maybeRunNextAsyncTest() {
  214. var doCalculation = true;
  215. if ( 1 <= SiteHealth.site_status.async.length ) {
  216. $.each( SiteHealth.site_status.async, function() {
  217. var data = {
  218. 'action': 'health-check-' + this.test.replace( '_', '-' ),
  219. '_wpnonce': SiteHealth.nonce.site_status
  220. };
  221. if ( this.completed ) {
  222. return true;
  223. }
  224. doCalculation = false;
  225. this.completed = true;
  226. if ( 'undefined' !== typeof( this.has_rest ) && this.has_rest ) {
  227. wp.apiRequest( {
  228. url: wp.url.addQueryArgs( this.test, { _locale: 'user' } ),
  229. headers: this.headers
  230. } )
  231. .done( function( response ) {
  232. /** This filter is documented in wp-admin/includes/class-wp-site-health.php */
  233. appendIssue( wp.hooks.applyFilters( 'site_status_test_result', response ) );
  234. } )
  235. .fail( function( response ) {
  236. var description;
  237. if ( 'undefined' !== typeof( response.responseJSON ) && 'undefined' !== typeof( response.responseJSON.message ) ) {
  238. description = response.responseJSON.message;
  239. } else {
  240. description = __( 'No details available' );
  241. }
  242. addFailedSiteHealthCheckNotice( this.url, description );
  243. } )
  244. .always( function() {
  245. maybeRunNextAsyncTest();
  246. } );
  247. } else {
  248. $.post(
  249. ajaxurl,
  250. data
  251. ).done( function( response ) {
  252. /** This filter is documented in wp-admin/includes/class-wp-site-health.php */
  253. appendIssue( wp.hooks.applyFilters( 'site_status_test_result', response.data ) );
  254. } ).fail( function( response ) {
  255. var description;
  256. if ( 'undefined' !== typeof( response.responseJSON ) && 'undefined' !== typeof( response.responseJSON.message ) ) {
  257. description = response.responseJSON.message;
  258. } else {
  259. description = __( 'No details available' );
  260. }
  261. addFailedSiteHealthCheckNotice( this.url, description );
  262. } ).always( function() {
  263. maybeRunNextAsyncTest();
  264. } );
  265. }
  266. return false;
  267. } );
  268. }
  269. if ( doCalculation ) {
  270. recalculateProgression();
  271. }
  272. }
  273. /**
  274. * Add the details of a failed asynchronous test to the list of test results.
  275. *
  276. * @since 5.6.0
  277. */
  278. function addFailedSiteHealthCheckNotice( url, description ) {
  279. var issue;
  280. issue = {
  281. 'status': 'recommended',
  282. 'label': __( 'A test is unavailable' ),
  283. 'badge': {
  284. 'color': 'red',
  285. 'label': __( 'Unavailable' )
  286. },
  287. 'description': '<p>' + url + '</p><p>' + description + '</p>',
  288. 'actions': ''
  289. };
  290. /** This filter is documented in wp-admin/includes/class-wp-site-health.php */
  291. appendIssue( wp.hooks.applyFilters( 'site_status_test_result', issue ) );
  292. }
  293. if ( 'undefined' !== typeof SiteHealth ) {
  294. if ( 0 === SiteHealth.site_status.direct.length && 0 === SiteHealth.site_status.async.length ) {
  295. recalculateProgression();
  296. } else {
  297. SiteHealth.site_status.issues = {
  298. 'good': 0,
  299. 'recommended': 0,
  300. 'critical': 0
  301. };
  302. }
  303. if ( 0 < SiteHealth.site_status.direct.length ) {
  304. $.each( SiteHealth.site_status.direct, function() {
  305. appendIssue( this );
  306. } );
  307. }
  308. if ( 0 < SiteHealth.site_status.async.length ) {
  309. maybeRunNextAsyncTest();
  310. } else {
  311. recalculateProgression();
  312. }
  313. }
  314. function getDirectorySizes() {
  315. var timestamp = ( new Date().getTime() );
  316. // After 3 seconds announce that we're still waiting for directory sizes.
  317. var timeout = window.setTimeout( function() {
  318. wp.a11y.speak( __( 'Please wait...' ) );
  319. }, 3000 );
  320. wp.apiRequest( {
  321. path: '/wp-site-health/v1/directory-sizes'
  322. } ).done( function( response ) {
  323. updateDirSizes( response || {} );
  324. } ).always( function() {
  325. var delay = ( new Date().getTime() ) - timestamp;
  326. $( '.health-check-wp-paths-sizes.spinner' ).css( 'visibility', 'hidden' );
  327. recalculateProgression();
  328. if ( delay > 3000 ) {
  329. /*
  330. * We have announced that we're waiting.
  331. * Announce that we're ready after giving at least 3 seconds
  332. * for the first announcement to be read out, or the two may collide.
  333. */
  334. if ( delay > 6000 ) {
  335. delay = 0;
  336. } else {
  337. delay = 6500 - delay;
  338. }
  339. window.setTimeout( function() {
  340. wp.a11y.speak( __( 'All site health tests have finished running.' ) );
  341. }, delay );
  342. } else {
  343. // Cancel the announcement.
  344. window.clearTimeout( timeout );
  345. }
  346. $( document ).trigger( 'site-health-info-dirsizes-done' );
  347. } );
  348. }
  349. function updateDirSizes( data ) {
  350. var copyButton = $( 'button.button.copy-button' );
  351. var clipboardText = copyButton.attr( 'data-clipboard-text' );
  352. $.each( data, function( name, value ) {
  353. var text = value.debug || value.size;
  354. if ( typeof text !== 'undefined' ) {
  355. clipboardText = clipboardText.replace( name + ': loading...', name + ': ' + text );
  356. }
  357. } );
  358. copyButton.attr( 'data-clipboard-text', clipboardText );
  359. pathsSizesSection.find( 'td[class]' ).each( function( i, element ) {
  360. var td = $( element );
  361. var name = td.attr( 'class' );
  362. if ( data.hasOwnProperty( name ) && data[ name ].size ) {
  363. td.text( data[ name ].size );
  364. }
  365. } );
  366. }
  367. if ( isDebugTab ) {
  368. if ( pathsSizesSection.length ) {
  369. getDirectorySizes();
  370. } else {
  371. recalculateProgression();
  372. }
  373. }
  374. // Trigger a class toggle when the extended menu button is clicked.
  375. $( '.health-check-offscreen-nav-wrapper' ).on( 'click', function() {
  376. $( this ).toggleClass( 'visible' );
  377. } );
  378. } );