PluginProbe ʕ •ᴥ•ʔ
WP-Sweep / trunk
WP-Sweep vtrunk
2.0.1 2.0.0 1.2.0 trunk 1.0.10 1.0.11 1.0.12 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.5 1.1.6 1.1.7 1.1.8 1.1.9
wp-sweep / js / wp-sweep-admin.js
wp-sweep / js Last commit date
index.php 3 weeks ago wp-sweep-admin.js 6 days ago
wp-sweep-admin.js
456 lines
1 /**
2 * WP-Sweep admin screen.
3 *
4 * Drives the Sweep and Details row actions against admin-ajax.php, keeps the
5 * running totals above the table up to date, and warns before the page is
6 * closed mid-sweep.
7 *
8 * The row actions are real, nonced links: with the script turned off they
9 * still sweep, they just reload the screen each time. Everything here is an
10 * enhancement over that, never a replacement for it.
11 *
12 * Listeners are delegated from `document`, so a row added by one of the
13 * wp_sweep_admin_*_sweep actions works without re-binding anything.
14 */
15 ( function() {
16 'use strict';
17
18 const l10n = window.wpSweepL10n || {};
19
20 /**
21 * Ask admin-ajax.php to run a sweep or fetch its details.
22 *
23 * @param {HTMLElement} trigger The row action that was clicked.
24 * @return {Promise<Object>} The decoded JSON response.
25 */
26 function request( trigger ) {
27 const params = new URLSearchParams();
28
29 // Named on the PHP side, so these stay snake_case. Setting them rather
30 // than writing an object literal keeps that out of the identifiers.
31 params.set( 'action', trigger.dataset.action );
32 params.set( 'sweep_name', trigger.dataset.sweepName );
33 params.set( 'sweep_type', trigger.dataset.sweepType );
34 params.set( '_wpnonce', trigger.dataset.nonce );
35
36 return fetch( window.ajaxurl + '?' + params.toString(), {
37 credentials: 'same-origin',
38 } ).then( function( response ) {
39 return response.json();
40 } );
41 }
42
43 /**
44 * Mark a row action as running, or let it go again.
45 *
46 * The triggers are anchors, so they have no disabled property to set --
47 * they are real, nonced links that work with the script turned off. A
48 * click on one that is already running is ignored instead.
49 *
50 * @param {HTMLElement} trigger The row action.
51 * @param {boolean} busy Whether it is running.
52 * @param {string} label The text to show.
53 */
54 function setBusy( trigger, busy, label ) {
55 trigger.setAttribute( 'aria-disabled', busy ? 'true' : 'false' );
56 trigger.textContent = label;
57 }
58
59 /**
60 * Find the region a row action reports its result into.
61 *
62 * The screen prints one, above the form, and every Sweep link names it in
63 * aria-controls -- so the id is written once, in PHP
64 * (WP_Sweep_Admin::MESSAGE_ID), and this follows the association instead of
65 * guessing at the markup between the two.
66 *
67 * That guess is what this used to be, and it had never once worked: it took
68 * the row's .table-sweep and walked backwards through its previous siblings
69 * looking for .sweep-message. Since the screen became one list table the
70 * table is inside the <form> and the region is outside it, so the walk ran
71 * out of siblings inside the form and returned null, showMessage() took its
72 * early return, and no sweep told anyone what it had done. The sweeps
73 * themselves ran correctly throughout, which is why nothing looked wrong
74 * beyond a count quietly changing.
75 *
76 * It reached nobody only because 2.0.0 has not shipped. Nothing in the
77 * plugin's own tests would have stopped it: the vitest fixture had been
78 * written to suit the walk -- region and table as adjacent siblings, no
79 * form -- so the assertion and the code agreed with each other and with
80 * nothing else. It took a browser to find it.
81 *
82 * @param {HTMLElement} trigger The row action that was clicked.
83 * @return {HTMLElement|null} The region, if the page has one.
84 */
85 function messageContainer( trigger ) {
86 const id = trigger.getAttribute( 'aria-controls' );
87
88 return id ? document.getElementById( id ) : null;
89 }
90
91 /**
92 * Render the list of items a sweep would remove.
93 *
94 * Every entry here comes out of the database — post titles, comment
95 * author names, meta keys, option names. Comment author names in
96 * particular are supplied by whoever left the comment, which is exactly
97 * the sort of person who leaves markup in them. They are written as text
98 * nodes rather than as HTML: before 2.0.0 this list was assembled by
99 * string concatenation and injected with .html(), so a spam comment
100 * signed with a script tag ran that script in the administrator's
101 * browser the moment Details was clicked.
102 *
103 * @param {HTMLElement} row The row the details belong to.
104 * @param {Array} items The items to list.
105 */
106 function renderDetails( row, items ) {
107 const target = row.querySelector( '.sweep-details' );
108
109 if ( ! target ) {
110 return;
111 }
112
113 const list = document.createElement( 'ol' );
114
115 items.forEach( function( item ) {
116 const entry = document.createElement( 'li' );
117 entry.textContent = item;
118 list.appendChild( entry );
119 } );
120
121 target.textContent = '';
122 target.appendChild( list );
123 target.hidden = false;
124 }
125
126 /**
127 * Clear and hide a row's details list.
128 *
129 * @param {HTMLElement} row The table row.
130 */
131 function hideDetails( row ) {
132 const target = row.querySelector( '.sweep-details' );
133
134 if ( target ) {
135 target.textContent = '';
136 target.hidden = true;
137 }
138 }
139
140 /**
141 * Write a count into its cell, emphasised only when it is worth acting on.
142 *
143 * The emphasis is the element itself -- a <strong> while there is
144 * something to sweep, a <span> once there is not -- so the element is
145 * replaced rather than restyled, exactly as a reload would redraw it.
146 * Replacing it also sheds the pending marker and the data attributes a
147 * deferred cell was rendered with, which is what stops a slow count
148 * response overwriting a row that has since been swept: the fetch checks
149 * its cell is still connected before writing.
150 *
151 * @param {HTMLElement} cell The current .sweep-count element.
152 * @param {number} count The count to show.
153 */
154 function writeCount( cell, count ) {
155 const replacement = document.createElement( count > 0 ? 'strong' : 'span' );
156
157 replacement.className = 'sweep-count';
158 replacement.textContent = count.toLocaleString();
159
160 cell.replaceWith( replacement );
161 }
162
163 /**
164 * Write the running totals above the table.
165 *
166 * @param {Object} stats Row counts keyed by table type.
167 */
168 function writeStats( stats ) {
169 Object.keys( stats || {} ).forEach( function( key ) {
170 document
171 .querySelectorAll( '.sweep-count-type-' + key )
172 .forEach( function( node ) {
173 node.classList.remove( 'sweep-total-pending' );
174 node.textContent = parseInt( stats[ key ], 10 ).toLocaleString();
175 } );
176 } );
177 }
178
179 /**
180 * Swap a row's buttons for the dash a fresh render gives an empty row.
181 *
182 * The checkbox stays: every row has one, empty or not, or the column gains
183 * holes and select-all starts claiming rows it does not select. This
184 * mirrors what column_actions() renders on a fresh page load, so a swept
185 * row and a reloaded one agree.
186 *
187 * @param {HTMLElement} row The table row.
188 */
189 function markRowEmpty( row ) {
190 const actions = row.querySelector( '.column-actions' );
191
192 if ( ! actions ) {
193 return;
194 }
195
196 actions.textContent = '';
197
198 const dash = document.createElement( 'span' );
199 dash.className = 'sweep-nothing';
200 dash.setAttribute( 'aria-hidden', 'true' );
201 dash.textContent = '';
202
203 const label = document.createElement( 'span' );
204 label.className = 'screen-reader-text';
205 label.textContent = l10n.textNothingToSweep;
206
207 actions.append( dash, label );
208 }
209
210 /**
211 * Show the result of a sweep in the region the trigger names.
212 *
213 * @param {HTMLElement} trigger The Sweep row action that was clicked.
214 * @param {string} text The message from the server.
215 */
216 function showMessage( trigger, text ) {
217 const container = messageContainer( trigger );
218
219 if ( ! container ) {
220 return;
221 }
222
223 // The same classes settings_errors() emits for the reload path, so the
224 // two messages are one message in two code paths rather than two
225 // different-looking ones. `updated` is the pre-4.1 vocabulary and the
226 // standard names notice-success instead; hand-rolling it here was also
227 // the one place the "no hand-rolled div.updated" rule was being broken,
228 // because it is JavaScript and the checker only reads PHP.
229 const notice = document.createElement( 'div' );
230 notice.className = 'notice notice-success';
231
232 const paragraph = document.createElement( 'p' );
233 paragraph.textContent = text;
234 notice.appendChild( paragraph );
235
236 container.textContent = '';
237 container.appendChild( notice );
238 }
239
240 /**
241 * Run one sweep and fold the result back into the page.
242 *
243 * @param {HTMLElement} trigger The Sweep row action that was clicked.
244 * @return {Promise} Resolves once the row has been updated.
245 */
246 function sweep( trigger ) {
247 const row = trigger.closest( 'tr' );
248
249 document.body.classList.add( 'sweep-active' );
250 setBusy( trigger, true, l10n.textSweeping );
251
252 return request( trigger )
253 .then( function( response ) {
254 if ( ! response || ! response.success ) {
255 return;
256 }
257
258 const count = parseInt( response.data.count, 10 );
259
260 const countCell = row.querySelector( '.sweep-count' );
261 if ( countCell ) {
262 writeCount( countCell, count );
263 }
264
265 const percentageCell = row.querySelector( '.sweep-percentage' );
266 if ( percentageCell ) {
267 percentageCell.textContent = response.data.percentage;
268 }
269
270 // Running totals for the whole section.
271 writeStats( response.data.stats );
272
273 showMessage( trigger, response.data.sweep );
274 hideDetails( row );
275
276 document.body.classList.remove( 'sweep-active' );
277
278 // Nothing left to sweep, so the buttons go and the cell says so.
279 if ( 0 === count ) {
280 markRowEmpty( row );
281
282 return;
283 }
284
285 setBusy( trigger, false, l10n.textSweep );
286 } )
287 .catch( function() {
288 document.body.classList.remove( 'sweep-active' );
289 setBusy( trigger, false, l10n.textSweep );
290 } );
291 }
292
293 /**
294 * Fetch one deferred count and fold it into its row.
295 *
296 * The cell was rendered pending, carrying the same action/name/type/nonce
297 * vocabulary the row actions carry, so request() reads it the same way.
298 *
299 * @param {HTMLElement} cell The pending .sweep-count element.
300 * @return {Promise} Resolves once the row has been updated.
301 */
302 function fillCount( cell ) {
303 return request( cell )
304 .then( function( response ) {
305 // Replaced meanwhile -- the row was swept before its count
306 // arrived, and the sweep's answer is the fresher one.
307 if ( ! cell.isConnected ) {
308 return;
309 }
310
311 if ( ! response || ! response.success ) {
312 cell.textContent = l10n.textNa;
313 return;
314 }
315
316 const row = cell.closest( 'tr' );
317 const count = parseInt( response.data.count, 10 );
318
319 writeCount( cell, count );
320
321 const percentageCell = row.querySelector( '.sweep-percentage' );
322 if ( percentageCell ) {
323 percentageCell.textContent = response.data.percentage;
324 }
325
326 if ( 0 === count ) {
327 markRowEmpty( row );
328 }
329 } )
330 .catch( function() {
331 cell.textContent = l10n.textNa;
332 } );
333 }
334
335 /**
336 * Fetch the running totals, which were also deferred.
337 *
338 * One request for the whole table: its nonce rides on the table element,
339 * since twelve cells share the answer.
340 *
341 * @return {Promise} Resolves once the totals have been written.
342 */
343 function fillTotals() {
344 const table = document.querySelector( '.sweep-totals[data-nonce]' );
345
346 if ( ! table || ! table.querySelector( '.sweep-total-pending' ) ) {
347 return Promise.resolve();
348 }
349
350 const params = new URLSearchParams();
351
352 params.set( 'action', 'wp_sweep_totals' );
353 params.set( '_wpnonce', table.dataset.nonce );
354
355 const fail = function() {
356 table.querySelectorAll( '.sweep-total-pending' ).forEach( function( node ) {
357 node.textContent = l10n.textNa;
358 } );
359 };
360
361 return fetch( window.ajaxurl + '?' + params.toString(), {
362 credentials: 'same-origin',
363 } )
364 .then( function( response ) {
365 return response.json();
366 } )
367 .then( function( response ) {
368 if ( ! response || ! response.success ) {
369 fail();
370 return;
371 }
372
373 writeStats( response.data.stats );
374 } )
375 .catch( fail );
376 }
377
378 /**
379 * Fill in everything the screen rendered without.
380 *
381 * The screen defers its counts so it can render before the queries run --
382 * they are the expensive half of the page, and computing all of them
383 * before printing a byte is what used to time the screen out on large
384 * databases. Totals first, then one row at a time: these queries scan the
385 * same handful of tables, and nineteen of them at once would hand the
386 * database the very spike the deferral exists to avoid.
387 */
388 function fillDeferredCounts() {
389 const cells = Array.prototype.slice.call(
390 document.querySelectorAll( '.sweep-count-pending' ),
391 );
392
393 cells.reduce( function( chain, cell ) {
394 return chain.then( function() {
395 return fillCount( cell );
396 } );
397 }, fillTotals() );
398 }
399
400 document.addEventListener( 'click', function( event ) {
401 const trigger = event.target.closest(
402 '.btn-sweep, .btn-sweep-details',
403 );
404
405 if ( ! trigger || 'true' === trigger.getAttribute( 'aria-disabled' ) ) {
406 return;
407 }
408
409 event.preventDefault();
410
411 if ( trigger.classList.contains( 'btn-sweep' ) ) {
412 sweep( trigger );
413 return;
414 }
415
416 if ( trigger.classList.contains( 'btn-sweep-details' ) ) {
417 const row = trigger.closest( 'tr' );
418 const shown = row.querySelector( '.sweep-details' );
419
420 // A toggle, not a one-way door. The list can be long, and the only
421 // way to put it away used to be reloading the screen.
422 if ( shown && ! shown.hidden ) {
423 hideDetails( row );
424 trigger.setAttribute( 'aria-expanded', 'false' );
425 return;
426 }
427
428 request( trigger ).then( function( response ) {
429 if ( response && response.success && response.data.length > 0 ) {
430 renderDetails( row, response.data );
431 trigger.setAttribute( 'aria-expanded', 'true' );
432 }
433 } );
434 }
435 } );
436
437 /*
438 * Page closing confirmation.
439 * https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
440 */
441 window.addEventListener( 'beforeunload', function( event ) {
442 if ( ! document.body.classList.contains( 'sweep-active' ) ) {
443 return undefined;
444 }
445
446 event.preventDefault();
447 event.returnValue = l10n.textCloseWarning;
448
449 return l10n.textCloseWarning;
450 } );
451
452 // The script is enqueued in the footer, so the cells it fills are already
453 // on the page by the time this runs.
454 fillDeferredCounts();
455 }() );
456