You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

484 lines
13 KiB

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