PluginProbe ʕ •ᴥ•ʔ
WP-Sweep / 2.0.0
WP-Sweep v2.0.0
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 2 weeks ago wp-sweep-admin.js 2 weeks ago
wp-sweep-admin.js
319 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 * Show the result of a sweep in the region the trigger names.
142 *
143 * @param {HTMLElement} trigger The Sweep row action that was clicked.
144 * @param {string} text The message from the server.
145 */
146 function showMessage( trigger, text ) {
147 const container = messageContainer( trigger );
148
149 if ( ! container ) {
150 return;
151 }
152
153 // The same classes settings_errors() emits for the reload path, so the
154 // two messages are one message in two code paths rather than two
155 // different-looking ones. `updated` is the pre-4.1 vocabulary and the
156 // standard names notice-success instead; hand-rolling it here was also
157 // the one place the "no hand-rolled div.updated" rule was being broken,
158 // because it is JavaScript and the checker only reads PHP.
159 const notice = document.createElement( 'div' );
160 notice.className = 'notice notice-success';
161
162 const paragraph = document.createElement( 'p' );
163 paragraph.textContent = text;
164 notice.appendChild( paragraph );
165
166 container.textContent = '';
167 container.appendChild( notice );
168 }
169
170 /**
171 * Run one sweep and fold the result back into the page.
172 *
173 * @param {HTMLElement} trigger The Sweep row action that was clicked.
174 * @return {Promise} Resolves once the row has been updated.
175 */
176 function sweep( trigger ) {
177 const row = trigger.closest( 'tr' );
178
179 document.body.classList.add( 'sweep-active' );
180 setBusy( trigger, true, l10n.textSweeping );
181
182 return request( trigger )
183 .then( function( response ) {
184 if ( ! response || ! response.success ) {
185 return;
186 }
187
188 const count = parseInt( response.data.count, 10 );
189
190 const countCell = row.querySelector( '.sweep-count' );
191 if ( countCell ) {
192 countCell.textContent = count.toLocaleString();
193
194 // The cell is rendered as a <strong> while there is
195 // something in it and a <span> once there is not, so the
196 // emphasis has to come off here too. Setting textContent
197 // leaves the element itself alone, which is the whole
198 // reason the emphasis is the element rather than a tag
199 // inside it -- but it does mean a swept row would keep a
200 // bold zero until somebody reloaded the page.
201 if ( 0 === count && 'STRONG' === countCell.tagName ) {
202 const plain = document.createElement( 'span' );
203
204 plain.className = countCell.className;
205 plain.textContent = countCell.textContent;
206
207 countCell.replaceWith( plain );
208 }
209 }
210
211 const percentageCell = row.querySelector( '.sweep-percentage' );
212 if ( percentageCell ) {
213 percentageCell.textContent = response.data.percentage;
214 }
215
216 // Running totals for the whole section.
217 Object.keys( response.data.stats || {} ).forEach( function( key ) {
218 document
219 .querySelectorAll( '.sweep-count-type-' + key )
220 .forEach( function( node ) {
221 node.textContent = parseInt(
222 response.data.stats[ key ],
223 10,
224 ).toLocaleString();
225 } );
226 } );
227
228 showMessage( trigger, response.data.sweep );
229 hideDetails( row );
230
231 document.body.classList.remove( 'sweep-active' );
232
233 // Nothing left to sweep, so the buttons go and the cell says so.
234 // The checkbox stays: every row has one, empty or not, or the
235 // column gains holes and select-all starts claiming rows it does
236 // not select. This mirrors what column_actions() renders on a
237 // fresh page load, so a swept row and a reloaded one agree.
238 if ( 0 === count ) {
239 const actions = row.querySelector( '.column-actions' );
240
241 if ( actions ) {
242 actions.textContent = '';
243
244 const dash = document.createElement( 'span' );
245 dash.className = 'sweep-nothing';
246 dash.setAttribute( 'aria-hidden', 'true' );
247 dash.textContent = '\u2014';
248
249 const label = document.createElement( 'span' );
250 label.className = 'screen-reader-text';
251 label.textContent = l10n.textNothingToSweep;
252
253 actions.append( dash, label );
254 }
255
256 return;
257 }
258
259 setBusy( trigger, false, l10n.textSweep );
260 } )
261 .catch( function() {
262 document.body.classList.remove( 'sweep-active' );
263 setBusy( trigger, false, l10n.textSweep );
264 } );
265 }
266
267 document.addEventListener( 'click', function( event ) {
268 const trigger = event.target.closest(
269 '.btn-sweep, .btn-sweep-details',
270 );
271
272 if ( ! trigger || 'true' === trigger.getAttribute( 'aria-disabled' ) ) {
273 return;
274 }
275
276 event.preventDefault();
277
278 if ( trigger.classList.contains( 'btn-sweep' ) ) {
279 sweep( trigger );
280 return;
281 }
282
283 if ( trigger.classList.contains( 'btn-sweep-details' ) ) {
284 const row = trigger.closest( 'tr' );
285 const shown = row.querySelector( '.sweep-details' );
286
287 // A toggle, not a one-way door. The list can be long, and the only
288 // way to put it away used to be reloading the screen.
289 if ( shown && ! shown.hidden ) {
290 hideDetails( row );
291 trigger.setAttribute( 'aria-expanded', 'false' );
292 return;
293 }
294
295 request( trigger ).then( function( response ) {
296 if ( response && response.success && response.data.length > 0 ) {
297 renderDetails( row, response.data );
298 trigger.setAttribute( 'aria-expanded', 'true' );
299 }
300 } );
301 }
302 } );
303
304 /*
305 * Page closing confirmation.
306 * https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
307 */
308 window.addEventListener( 'beforeunload', function( event ) {
309 if ( ! document.body.classList.contains( 'sweep-active' ) ) {
310 return undefined;
311 }
312
313 event.preventDefault();
314 event.returnValue = l10n.textCloseWarning;
315
316 return l10n.textCloseWarning;
317 } );
318 }() );
319