From ab9e5f9c278c67a6cfd21410bd9db0824ea53761 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Wed, 21 Oct 2026 20:50:10 +0100
Subject: [PATCH] feat: Drive search progress through the admin bar
Pending searches show the wait as an info alert; running searches update the shared meter instead of painting a local one.
---
admin/views/FindReplace/results.php | 15 ++++--------
assets/css/admin.min.css | 2 +-
assets/js/admin.min.js | 2 +-
assets/js/components/Progress.js | 36 ++++++++---------------------
assets/sass/admin.scss | 20 ----------------
5 files changed, 17 insertions(+), 58 deletions(-)
diff --git a/admin/views/FindReplace/results.php b/admin/views/FindReplace/results.php
index 33d7c48..d196f76 100644
--- a/admin/views/FindReplace/results.php
+++ b/admin/views/FindReplace/results.php
@@ -27,7 +27,6 @@
[$badgeClass, $badgeLabel] = $badges[$search->status()] ?? ['badge-secondary', $search->status()];
$scanned = $search->tablesScanned();
$totalTables = $search->tablesTotal();
-$percent = $totalTables > 0 ? (int) floor(($scanned / $totalTables) * 100) : 0;
$running = $search->status() === Search::STATUS_RUNNING;
?>
@@ -106,15 +105,11 @@
if ($search->isOpen()) {
?>
-
- =$running ? 'Scanning tables.' : 'Waiting for a worker to start this search.'?>
-
- >
-
-
-
- =$scanned?> / =$totalTables?>
-
+ >
+ Waiting for a worker to start this search.
+
+ >Scanning tables.
+ >
span{display:block;height:100%;background:#337ab7}.find-replace-progress{font-variant-numeric:tabular-nums;white-space:nowrap}.find-replace-error{margin-top:.35rem;color:#a94442}
+.find-replace-results mark,.find-replace-snippet mark,.find-replace-preview mark{padding:0 .15em}.find-replace-snippet mark{background-color:#c8f7c5}.find-replace-preview mark{background-color:#c5ddf7}.find-replace-results td.actions .btn{margin-bottom:.25rem}[data-find-replace-status]{font-variant-numeric:tabular-nums}.find-replace-searches{margin-top:2.5rem}.find-replace-searches .find-replace-status,.find-replace-searches .find-replace-progress,.find-replace-searches .find-replace-results,.find-replace-searches .find-replace-last-run,.find-replace-searches .actions{width:250px}.find-replace-detail__term{font-size:1.25rem}.find-replace-detail__arrow{margin:0 .35rem;color:#6c757d}.find-replace-detail__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .75rem}.find-replace-detail__facts{display:flex;flex-wrap:wrap;gap:0 1.5rem}.find-replace-detail__fact{display:flex;flex-direction:column;gap:.15rem}.find-replace-detail__fact+.find-replace-detail__fact{padding-left:1.5rem;border-left:1px solid #dee2e6}.find-replace-detail__fact-label{color:#6c757d;font-size:.75rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase}.find-replace-detail__fact-value{font-size:1rem;font-variant-numeric:tabular-nums}.find-replace-detail__scope{color:#6c757d}.find-replace-progress{font-variant-numeric:tabular-nums;white-space:nowrap}.find-replace-error{margin-top:.35rem;color:#a94442}
diff --git a/assets/js/admin.min.js b/assets/js/admin.min.js
index 800bb60..2038568 100644
--- a/assets/js/admin.min.js
+++ b/assets/js/admin.min.js
@@ -1 +1 @@
-!function(){"use strict";var e=class{constructor(e){this.adminController=e,this.pendingScope="selected",this.modal=null,e.onRefreshUi(()=>{this.bind()}),this.bind()}dialog(){return this.modal||(this.modal=this.adminController.getInstance("Modal").create(),this.modal.setTitle("Replace text?").addAction("Replace",["btn-danger"],()=>{this.apply()}).addAction("Cancel",["btn-default"],()=>{this.modal.hide()})),this.modal}show(e){const t=document.createElement("p");t.textContent=e,this.dialog().setBody(t).show()}apply(){const e=document.getElementById("find-replace-results");if(!e)return;let t=e.querySelector('input[type="hidden"][name="scope"]');t||(t=document.createElement("input"),t.type="hidden",t.name="scope",e.appendChild(t)),t.value=this.pendingScope,e.submit()}bind(){const e=document.getElementById("find-replace-results");e&&!e.classList.contains("js-find-replace-bound")&&(e.classList.add("js-find-replace-bound"),e.addEventListener("change",t=>{t.target.matches(".js-toggle-all")&&e.querySelectorAll('input[name="result_ids[]"]').forEach(e=>{e.checked=t.target.checked}),t.target.matches('.js-toggle-all, input[name="result_ids[]"]')&&this.sync(e)})),document.querySelectorAll(".js-find-replace-apply:not(.processed)").forEach(t=>{t.classList.add("processed"),t.addEventListener("click",s=>{s.preventDefault(),this.pendingScope=t.getAttribute("data-scope")||"selected",this.show(this.message(e,this.pendingScope))})}),this.sync(e)}message(e,t){if("all"===t)return e&&"1"===e.getAttribute("data-filtered")?"This updates every saved result that matches the current filters.":"This updates every saved result for this search.";const s=this.selectedCount(e);return`This updates ${s} selected ${1===s?"record":"records"}.`}selectedCount(e){return e?e.querySelectorAll('input[name="result_ids[]"]:checked').length:0}sync(e){if(!e)return;const t=e.querySelector('.js-find-replace-apply[data-scope="selected"]');t&&(t.disabled=0===this.selectedCount(e))}};const t={PENDING:{variant:"badge-warning",label:"Pending"},RUNNING:{variant:"badge-info",label:"Running"},COMPLETE:{variant:"badge-success",label:"Complete"},FAILED:{variant:"badge-danger",label:"Failed"}};function s(e){return t[e]||{variant:"badge-secondary",label:e||"Unknown"}}const n={PENDING:"Waiting for a worker to start this search.",RUNNING:"Scanning tables."};var a=class{constructor(){document.querySelectorAll("[data-find-replace-status]").forEach(e=>{this.watch(e)})}watch(e){const t=e.getAttribute("data-url");t&&window.setTimeout(()=>{fetch(t,{credentials:"same-origin",headers:{Accept:"application/json","X-Requested-With":"XMLHttpRequest"}}).then(e=>e.json()).then(t=>{const s=t&&t.data&&!Array.isArray(t.data)?t.data:t;this.paint(e,s),"COMPLETE"!==s.status&&"FAILED"!==s.status?this.watch(e):window.location.reload()}).catch(()=>{this.watch(e)})},2e3)}paint(e,t){const a=t.status||"",r=e.querySelector("[data-find-replace-badge]"),i=e.querySelector("[data-find-replace-message]"),c=e.querySelector("[data-find-replace-meter]"),l=e.querySelector("[data-find-replace-bar]"),o=e.querySelector(".find-replace-progress"),d=Number(t.tables_scanned)||0,h=Number(t.tables_total)||0,u=h>0?Math.min(100,Math.floor(d/h*100)):0;r&&function(e,t){const n=s(t);e.className="badge "+n.variant,e.textContent=n.label}(r,a),i&&n[a]&&(i.textContent=n[a]),c&&(c.hidden="RUNNING"!==a),l&&(l.style.width=u+"%"),o&&(o.textContent=d+" / "+h);const p=e.querySelector(".find-replace-meter");p&&(p.setAttribute("aria-valuemax",String(h)),p.setAttribute("aria-valuenow",String(d)))}},r=class{constructor(){this.root=document.getElementById("find-replace"),this.form=document.getElementById("find-replace-form"),this.rows=document.getElementById("find-replace-searches"),this.alert=document.getElementById("find-replace-alert"),this.items=[],this.timer=null,this.root&&this.form&&this.rows&&(this.api=this.root.getAttribute("data-api"),this.form.addEventListener("submit",e=>{e.preventDefault(),this.create()}),this.load())}create(){const e=new FormData(this.form),t=this.form.querySelector('button[type="submit"]');t&&(t.disabled=!0),this.request(this.api,"POST",{term:e.get("term")||"",replacement:e.get("replacement")||"",case_sensitive:e.getAll("case_sensitive"),include_tables:e.get("include_tables")||"",exclude_tables:e.get("exclude_tables")||""}).then(()=>(this.message("success","Search queued."),this.form.reset(),this.restoreCaseSensitive(),this.load())).catch(e=>{this.message("danger",e.message)}).finally(()=>{t&&(t.disabled=!1)})}restoreCaseSensitive(){const e=this.form.querySelector('input[type="checkbox"][name="case_sensitive"]');e&&(e.checked=!0)}load(){return window.clearTimeout(this.timer),this.request(this.api,"GET").then(e=>{const t=Array.isArray(e.data)?e.data:[];this.render(t);const s=t.some(e=>"PENDING"===e.status||"RUNNING"===e.status);this.timer=window.setTimeout(()=>this.load(),s?2500:6e4)}).catch(e=>{this.message("danger",e.message),this.timer=window.setTimeout(()=>this.load(),6e4)})}render(e){const t=e.map(e=>e.id);e.forEach(e=>{const t=this.items.find(t=>t.id===e.id);t?this.update(t.node,e):this.items.push({id:e.id,node:this.add(e)})});for(let e=this.items.length-1;e>=0;e--)t.includes(this.items[e].id)||(this.items[e].node.remove(),this.items.splice(e,1));0===this.items.length&&(this.rows.textContent="",this.rows.append(this.empty("No searches yet")))}add(e){const t=this.rows.querySelector(".no-data");t&&t.closest("tr").remove();const s=document.createElement("tr");return s.append(this.cell("term",this.link(e)),this.cell("status"),this.cell("progress"),this.cell("results"),this.cell("last-run"),this.actions(e)),this.update(s,e),this.rows.append(s),s}update(e,t){const n=e.querySelector(".find-replace-term").querySelector("a");n&&(n.href=t.results_url,n.textContent=t.term);const a=e.querySelector(".find-replace-status");if(a.textContent="",a.append(function(e){const t=s(e),n=document.createElement("span");return n.className="badge "+t.variant,n.textContent=t.label,n}(t.status)),t.error){const e=document.createElement("div");e.className="find-replace-error",e.textContent=t.error,a.append(e)}e.querySelector(".find-replace-progress").textContent=t.tables_scanned+" / "+t.tables_total,e.querySelector(".find-replace-results").textContent=String(t.result_count),e.querySelector(".find-replace-last-run").textContent=t.last_run||"";const r=e.querySelector('[data-action="persistent"]');r.textContent=t.persistent?"Stop keeping":"Keep",r.dataset.persistent=t.persistent?"0":"1"}actions(e){const t=document.createElement("td");return t.className="actions",t.append(this.button("Keep","persistent",()=>this.persist(e.id,t)),this.button("Run again","rerun",()=>this.rerun(e.id)),this.button("Delete","delete",()=>this.remove(e.id),"btn-danger")),t}persist(e,t){const s=t.querySelector('[data-action="persistent"]');this.request(this.api+"/persistent","POST",{id:e,persistent:"1"===s.dataset.persistent}).then(()=>this.load()).catch(e=>this.message("danger",e.message))}rerun(e){this.request(this.api+"/rerun","POST",{id:e}).then(()=>this.load()).catch(e=>this.message("danger",e.message))}remove(e){window.confirm("Delete this search and its results?")&&this.request(this.api+"/delete","POST",{id:e}).then(()=>this.load()).catch(e=>this.message("danger",e.message))}button(e,t,s,n){const a=document.createElement("button");return a.type="button",a.className="btn btn-sm "+(n||"btn-default"),a.dataset.action=t,a.textContent=e,a.addEventListener("click",s),a}cell(e,t){const s=document.createElement("td");return s.className="find-replace-"+e,t&&s.append(t),s}link(e){const t=document.createElement("a");return t.href=e.results_url,t.textContent=e.term,t}empty(e){const t=document.createElement("tr"),s=document.createElement("td");return s.colSpan=6,s.className="no-data",s.textContent=e,t.append(s),t}message(e,t){this.alert&&(this.alert.hidden=!1,this.alert.className="alert alert-"+e,this.alert.textContent=t)}request(e,t,s){const n={method:t,credentials:"same-origin",headers:{Accept:"application/json","X-Requested-With":"XMLHttpRequest"}};return s&&(n.headers["Content-Type"]="application/json",n.body=JSON.stringify(s)),fetch(e,n).then(e=>e.json().then(t=>{if(!e.ok)throw new Error(t.error||"The request failed.");return t}))}};window.NAILS.ADMIN.registerPlugin("nails/module-find-replace","Confirm",t=>new e(t)).registerPlugin("nails/module-find-replace","Progress",()=>new a).registerPlugin("nails/module-find-replace","Searches",()=>new r)}();
\ No newline at end of file
+(()=>{"use strict";const e=class{constructor(e){this.adminController=e,this.pendingScope="selected",this.modal=null,e.onRefreshUi(()=>{this.bind()}),this.bind()}dialog(){return this.modal||(this.modal=this.adminController.getInstance("Modal").create(),this.modal.setTitle("Replace text?").addAction("Replace",["btn-danger"],()=>{this.apply()}).addAction("Cancel",["btn-default"],()=>{this.modal.hide()})),this.modal}show(e){const t=document.createElement("p");t.textContent=e,this.dialog().setBody(t).show()}apply(){const e=document.getElementById("find-replace-results");if(!e)return;let t=e.querySelector('input[type="hidden"][name="scope"]');t||(t=document.createElement("input"),t.type="hidden",t.name="scope",e.appendChild(t)),t.value=this.pendingScope,e.submit()}bind(){const e=document.getElementById("find-replace-results");e&&!e.classList.contains("js-find-replace-bound")&&(e.classList.add("js-find-replace-bound"),e.addEventListener("change",t=>{t.target.matches(".js-toggle-all")&&e.querySelectorAll('input[name="result_ids[]"]').forEach(e=>{e.checked=t.target.checked}),t.target.matches('.js-toggle-all, input[name="result_ids[]"]')&&this.sync(e)})),document.querySelectorAll(".js-find-replace-apply:not(.processed)").forEach(t=>{t.classList.add("processed"),t.addEventListener("click",s=>{s.preventDefault(),this.pendingScope=t.getAttribute("data-scope")||"selected",this.show(this.message(e,this.pendingScope))})}),this.sync(e)}message(e,t){if("all"===t)return e&&"1"===e.getAttribute("data-filtered")?"This updates every saved result that matches the current filters.":"This updates every saved result for this search.";const s=this.selectedCount(e);return`This updates ${s} selected ${1===s?"record":"records"}.`}selectedCount(e){return e?e.querySelectorAll('input[name="result_ids[]"]:checked').length:0}sync(e){if(!e)return;const t=e.querySelector('.js-find-replace-apply[data-scope="selected"]');t&&(t.disabled=0===this.selectedCount(e))}},t={PENDING:{variant:"badge-warning",label:"Pending"},RUNNING:{variant:"badge-info",label:"Running"},COMPLETE:{variant:"badge-success",label:"Complete"},FAILED:{variant:"badge-danger",label:"Failed"}};function s(e){return t[e]||{variant:"badge-secondary",label:e||"Unknown"}}const n=class{constructor(){document.querySelectorAll("[data-find-replace-status]").forEach(e=>{this.watch(e)})}watch(e){const t=e.getAttribute("data-url");t&&window.setTimeout(()=>{fetch(t,{credentials:"same-origin",headers:{Accept:"application/json","X-Requested-With":"XMLHttpRequest"}}).then(e=>e.json()).then(t=>{const s=t&&t.data&&!Array.isArray(t.data)?t.data:t;this.paint(e,s),"COMPLETE"!==s.status&&"FAILED"!==s.status?this.watch(e):window.location.reload()}).catch(()=>{this.watch(e)})},2e3)}paint(e,t){const n=t.status||"",a=e.querySelector("[data-find-replace-badge]"),r=e.querySelector("[data-find-replace-message]"),i=e.querySelector("[data-find-replace-scanning]"),c=e.querySelector(".js-admin-progress"),l=Number(t.tables_scanned)||0,d=Number(t.tables_total)||0;a&&function(e,t){const n=s(t);e.className="badge "+n.variant,e.textContent=n.label}(a,n),r&&(r.hidden="PENDING"!==n),i&&(i.hidden="RUNNING"!==n),c&&(c.hidden="RUNNING"!==n,c.dataset.max=String(d),c.dataset.value=String(l))}},a=class{constructor(){this.root=document.getElementById("find-replace"),this.form=document.getElementById("find-replace-form"),this.rows=document.getElementById("find-replace-searches"),this.alert=document.getElementById("find-replace-alert"),this.items=[],this.timer=null,this.root&&this.form&&this.rows&&(this.api=this.root.getAttribute("data-api"),this.form.addEventListener("submit",e=>{e.preventDefault(),this.create()}),this.load())}create(){const e=new FormData(this.form),t=this.form.querySelector('button[type="submit"]');t&&(t.disabled=!0),this.request(this.api,"POST",{term:e.get("term")||"",replacement:e.get("replacement")||"",case_sensitive:e.getAll("case_sensitive"),include_tables:e.get("include_tables")||"",exclude_tables:e.get("exclude_tables")||""}).then(()=>(this.message("success","Search queued."),this.form.reset(),this.restoreCaseSensitive(),this.load())).catch(e=>{this.message("danger",e.message)}).finally(()=>{t&&(t.disabled=!1)})}restoreCaseSensitive(){const e=this.form.querySelector('input[type="checkbox"][name="case_sensitive"]');e&&(e.checked=!0)}load(){return window.clearTimeout(this.timer),this.request(this.api,"GET").then(e=>{const t=Array.isArray(e.data)?e.data:[];this.render(t);const s=t.some(e=>"PENDING"===e.status||"RUNNING"===e.status);this.timer=window.setTimeout(()=>this.load(),s?2500:6e4)}).catch(e=>{this.message("danger",e.message),this.timer=window.setTimeout(()=>this.load(),6e4)})}render(e){const t=e.map(e=>e.id);e.forEach(e=>{const t=this.items.find(t=>t.id===e.id);t?this.update(t.node,e):this.items.push({id:e.id,node:this.add(e)})});for(let e=this.items.length-1;e>=0;e--)t.includes(this.items[e].id)||(this.items[e].node.remove(),this.items.splice(e,1));0===this.items.length&&(this.rows.textContent="",this.rows.append(this.empty("No searches yet")))}add(e){const t=this.rows.querySelector(".no-data");t&&t.closest("tr").remove();const s=document.createElement("tr");return s.append(this.cell("term",this.link(e)),this.cell("status"),this.cell("progress"),this.cell("results"),this.cell("last-run"),this.actions(e)),this.update(s,e),this.rows.append(s),s}update(e,t){const n=e.querySelector(".find-replace-term").querySelector("a");n&&(n.href=t.results_url,n.textContent=t.term);const a=e.querySelector(".find-replace-status");if(a.textContent="",a.append(function(e){const t=s(e),n=document.createElement("span");return n.className="badge "+t.variant,n.textContent=t.label,n}(t.status)),t.error){const e=document.createElement("div");e.className="find-replace-error",e.textContent=t.error,a.append(e)}e.querySelector(".find-replace-progress").textContent=t.tables_scanned+" / "+t.tables_total,e.querySelector(".find-replace-results").textContent=String(t.result_count),e.querySelector(".find-replace-last-run").textContent=t.last_run||"";const r=e.querySelector('[data-action="persistent"]');r.textContent=t.persistent?"Stop keeping":"Keep",r.dataset.persistent=t.persistent?"0":"1"}actions(e){const t=document.createElement("td");return t.className="actions",t.append(this.button("Keep","persistent",()=>this.persist(e.id,t)),this.button("Run again","rerun",()=>this.rerun(e.id)),this.button("Delete","delete",()=>this.remove(e.id),"btn-danger")),t}persist(e,t){const s=t.querySelector('[data-action="persistent"]');this.request(this.api+"/persistent","POST",{id:e,persistent:"1"===s.dataset.persistent}).then(()=>this.load()).catch(e=>this.message("danger",e.message))}rerun(e){this.request(this.api+"/rerun","POST",{id:e}).then(()=>this.load()).catch(e=>this.message("danger",e.message))}remove(e){window.confirm("Delete this search and its results?")&&this.request(this.api+"/delete","POST",{id:e}).then(()=>this.load()).catch(e=>this.message("danger",e.message))}button(e,t,s,n){const a=document.createElement("button");return a.type="button",a.className="btn btn-sm "+(n||"btn-default"),a.dataset.action=t,a.textContent=e,a.addEventListener("click",s),a}cell(e,t){const s=document.createElement("td");return s.className="find-replace-"+e,t&&s.append(t),s}link(e){const t=document.createElement("a");return t.href=e.results_url,t.textContent=e.term,t}empty(e){const t=document.createElement("tr"),s=document.createElement("td");return s.colSpan=6,s.className="no-data",s.textContent=e,t.append(s),t}message(e,t){this.alert&&(this.alert.hidden=!1,this.alert.className="alert alert-"+e,this.alert.textContent=t)}request(e,t,s){const n={method:t,credentials:"same-origin",headers:{Accept:"application/json","X-Requested-With":"XMLHttpRequest"}};return s&&(n.headers["Content-Type"]="application/json",n.body=JSON.stringify(s)),fetch(e,n).then(e=>e.json().then(t=>{if(!e.ok)throw new Error(t.error||"The request failed.");return t}))}};window.NAILS.ADMIN.registerPlugin("nails/module-find-replace","Confirm",t=>new e(t)).registerPlugin("nails/module-find-replace","Progress",()=>new n).registerPlugin("nails/module-find-replace","Searches",()=>new a)})();
\ No newline at end of file
diff --git a/assets/js/components/Progress.js b/assets/js/components/Progress.js
index cba1305..60a5bfd 100644
--- a/assets/js/components/Progress.js
+++ b/assets/js/components/Progress.js
@@ -1,10 +1,5 @@
import {paintBadge} from './Status.js';
-const MESSAGES = {
- PENDING: 'Waiting for a worker to start this search.',
- RUNNING: 'Scanning tables.',
-};
-
class Progress {
constructor() {
document.querySelectorAll('[data-find-replace-status]').forEach((node) => {
@@ -49,38 +44,27 @@ class Progress {
const status = payload.status || '';
const badgeNode = node.querySelector('[data-find-replace-badge]');
const message = node.querySelector('[data-find-replace-message]');
- const meter = node.querySelector('[data-find-replace-meter]');
- const bar = node.querySelector('[data-find-replace-bar]');
- const progress = node.querySelector('.find-replace-progress');
+ const scanning = node.querySelector('[data-find-replace-scanning]');
+ const meter = node.querySelector('.js-admin-progress');
const scanned = Number(payload.tables_scanned) || 0;
const total = Number(payload.tables_total) || 0;
- const percent = total > 0 ? Math.min(100, Math.floor((scanned / total) * 100)) : 0;
if (badgeNode) {
paintBadge(badgeNode, status);
}
- if (message && MESSAGES[status]) {
- message.textContent = MESSAGES[status];
- }
-
- if (meter) {
- meter.hidden = status !== 'RUNNING';
- }
-
- if (bar) {
- bar.style.width = percent + '%';
+ if (message) {
+ message.hidden = status !== 'PENDING';
}
- if (progress) {
- progress.textContent = scanned + ' / ' + total;
+ if (scanning) {
+ scanning.hidden = status !== 'RUNNING';
}
- const track = node.querySelector('.find-replace-meter');
-
- if (track) {
- track.setAttribute('aria-valuemax', String(total));
- track.setAttribute('aria-valuenow', String(scanned));
+ if (meter) {
+ meter.hidden = status !== 'RUNNING';
+ meter.dataset.max = String(total);
+ meter.dataset.value = String(scanned);
}
}
}
diff --git a/assets/sass/admin.scss b/assets/sass/admin.scss
index d6b351e..611b2e8 100644
--- a/assets/sass/admin.scss
+++ b/assets/sass/admin.scss
@@ -86,26 +86,6 @@
color: #6c757d;
}
-.find-replace-meter-row {
- display: flex;
- align-items: center;
- gap: 0.75rem;
-}
-
-.find-replace-meter {
- flex: 1;
- height: 0.5rem;
- overflow: hidden;
- border-radius: 1rem;
- background: #e9ecef;
-}
-
-.find-replace-meter > span {
- display: block;
- height: 100%;
- background: #337ab7;
-}
-
.find-replace-progress {
font-variant-numeric: tabular-nums;
white-space: nowrap;