refactor(admin): unify confirm dialog — pure hx-confirm + form[method=dialog]
Eliminate `confirmAction()` and the duplicate `cloneNode` hack entirely. One code path: everything goes through `showConfirm()` called by the `htmx:confirm` interceptor. Dialog HTML: - `<form method="dialog">` for native close semantics; button `value` becomes `dialog.returnValue` — no manual event listener reassignment. JS: - `showConfirm(message)` — Promise-based, listens for `close` once. - `htmx:confirm` handler calls `showConfirm()` and calls `issueRequest` if confirmed. Replaces both the old HTMX handler and `confirmAction()`. Templates (Padelnomics, 14 files): - All `onclick=confirmAction(...)` and `onclick=confirm()` removed. - Form-submit buttons: added `hx-boost="true"` to form + `hx-confirm` on the submit button. - Pure HTMX buttons (pipeline_transform, pipeline_overview): `hx-confirm` replaces `onclick=if(!confirm(...))return false;`. Pipeline routes (pipeline_trigger_extract, pipeline_trigger_transform): - `is_htmx` now excludes `HX-Boosted: true` requests — boosted form POSTs get the normal redirect instead of the inline partial. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **Unified confirm dialog — pure HTMX `hx-confirm` + `<form method="dialog">`** — eliminated the `confirmAction()` JS function and the duplicate `cloneNode` hack. All confirmation prompts now go through a single `showConfirm()` Promise-based function called by the `htmx:confirm` interceptor. The dialog HTML uses `<form method="dialog">` for native close semantics (`returnValue` is `"ok"` or `"cancel"`), removing the need to clone and replace buttons on every invocation. All 12 Padelnomics call sites converted from `onclick=confirmAction(...)` to `hx-boost="true"` + `hx-confirm="..."` on the submit button. Pipeline trigger endpoints updated to treat `HX-Boosted: true` requests as non-HTMX (returning a redirect rather than an inline partial) so boosted form submissions flow through the normal redirect cycle. Same changes applied to BeanFlows and the quart-saas-boilerplate template.
|
||||
- `web/src/padelnomics/admin/templates/admin/base_admin.html`: replaced dialog `<div>` with `<form method="dialog">`, replaced `confirmAction()` + inline `htmx:confirm` handler with unified `showConfirm()` + single `htmx:confirm` listener
|
||||
- `web/src/padelnomics/admin/pipeline_routes.py`: `pipeline_trigger_extract` and `pipeline_trigger_transform` now exclude `HX-Boosted: true` from the HTMX partial path
|
||||
- 12 templates updated: `pipeline.html`, `partials/pipeline_extractions.html`, `affiliate_form.html`, `affiliate_program_form.html`, `partials/affiliate_program_results.html`, `partials/affiliate_row.html`, `generate_form.html`, `articles.html`, `audience_contacts.html`, `template_detail.html`, `partials/scenario_results.html`
|
||||
- Same changes mirrored to BeanFlows and quart-saas-boilerplate template
|
||||
|
||||
- **Per-proxy dead tracking in tiered cycler** — `make_tiered_cycler` now accepts a `proxy_failure_limit` parameter (default 3). Individual proxies that hit the limit are marked dead and permanently skipped by `next_proxy()`. If all proxies in the active tier are dead, `next_proxy()` auto-escalates to the next tier without needing the tier-level threshold. `record_failure(proxy_url)` and `record_success(proxy_url)` accept an optional `proxy_url` argument for per-proxy tracking; callers without `proxy_url` are fully backward-compatible. New `dead_proxy_count()` callable exposed for monitoring.
|
||||
- `extract/padelnomics_extract/src/padelnomics_extract/proxy.py`: added per-proxy state (`proxy_failure_counts`, `dead_proxies`), updated `next_proxy`/`record_failure`/`record_success`, added `dead_proxy_count`
|
||||
- `extract/padelnomics_extract/src/padelnomics_extract/playtomic_tenants.py`: `_fetch_page_via_cycler` passes `proxy_url` to `record_success`/`record_failure`
|
||||
|
||||
@@ -780,7 +780,8 @@ async def pipeline_trigger_extract():
|
||||
else:
|
||||
await enqueue("run_extraction")
|
||||
|
||||
is_htmx = request.headers.get("HX-Request") == "true"
|
||||
is_htmx = (request.headers.get("HX-Request") == "true"
|
||||
and request.headers.get("HX-Boosted") != "true")
|
||||
if is_htmx:
|
||||
return await _render_overview_partial()
|
||||
|
||||
@@ -1005,7 +1006,8 @@ async def pipeline_trigger_transform():
|
||||
(task_name,),
|
||||
)
|
||||
if existing:
|
||||
is_htmx = request.headers.get("HX-Request") == "true"
|
||||
is_htmx = (request.headers.get("HX-Request") == "true"
|
||||
and request.headers.get("HX-Boosted") != "true")
|
||||
if is_htmx:
|
||||
return await _render_transform_partial()
|
||||
await flash(f"A '{step}' task is already queued (task #{existing['id']}).", "warning")
|
||||
@@ -1013,7 +1015,8 @@ async def pipeline_trigger_transform():
|
||||
|
||||
await enqueue(task_name)
|
||||
|
||||
is_htmx = request.headers.get("HX-Request") == "true"
|
||||
is_htmx = (request.headers.get("HX-Request") == "true"
|
||||
and request.headers.get("HX-Boosted") != "true")
|
||||
if is_htmx:
|
||||
return await _render_transform_partial()
|
||||
|
||||
|
||||
@@ -226,10 +226,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
<a href="{{ url_for('admin.affiliate_products') }}" class="btn-outline">Cancel</a>
|
||||
</div>
|
||||
{% if editing %}
|
||||
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product_id) }}" style="margin:0">
|
||||
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product_id) }}" style="margin:0" hx-boost="true">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline"
|
||||
onclick="return confirm('Delete this product? This cannot be undone.')">Delete</button>
|
||||
hx-confirm="Delete this product? This cannot be undone.">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -120,10 +120,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
<a href="{{ url_for('admin.affiliate_programs') }}" class="btn-outline">Cancel</a>
|
||||
</div>
|
||||
{% if editing %}
|
||||
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=program_id) }}" style="margin:0">
|
||||
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=program_id) }}" style="margin:0" hx-boost="true">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline"
|
||||
onclick="return confirm('Delete this program? Blocked if products reference it.')">Delete</button>
|
||||
hx-confirm="Delete this program? Blocked if products reference it.">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('admin.article_new') }}" class="btn btn-sm">New Article</a>
|
||||
<form method="post" action="{{ url_for('admin.rebuild_all') }}" class="m-0" style="display:inline">
|
||||
<form method="post" action="{{ url_for('admin.rebuild_all') }}" class="m-0" style="display:inline" hx-boost="true">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="button" class="btn-outline btn-sm" onclick="confirmAction('Rebuild all articles? This will re-render every article from its template.', this.closest('form'))">Rebuild All</button>
|
||||
<button type="submit" class="btn-outline btn-sm"
|
||||
hx-confirm="Rebuild all articles? This will re-render every article from its template.">Rebuild All</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -27,10 +27,11 @@
|
||||
<td class="text-sm">{{ c.email if c.email is defined else (c.get('email', '-') if c is mapping else '-') }}</td>
|
||||
<td class="mono text-sm">{{ (c.created_at if c.created_at is defined else (c.get('created_at', '-') if c is mapping else '-'))[:16] if c else '-' }}</td>
|
||||
<td style="text-align:right">
|
||||
<form method="post" action="{{ url_for('admin.audience_contact_remove', audience_id=audience.audience_id) }}" style="display:inline">
|
||||
<form method="post" action="{{ url_for('admin.audience_contact_remove', audience_id=audience.audience_id) }}" style="display:inline" hx-boost="true">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="contact_id" value="{{ c.id if c.id is defined else (c.get('id', '') if c is mapping else '') }}">
|
||||
<button type="button" class="btn-outline btn-sm" style="color:#DC2626" onclick="confirmAction('Remove this contact from the audience?', this.closest('form'))">Remove</button>
|
||||
<button type="submit" class="btn-outline btn-sm" style="color:#DC2626"
|
||||
hx-confirm="Remove this contact from the audience?">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -228,21 +228,29 @@
|
||||
|
||||
<dialog id="confirm-dialog">
|
||||
<p id="confirm-msg"></p>
|
||||
<div class="dialog-actions">
|
||||
<button id="confirm-cancel" class="btn-outline btn-sm">Cancel</button>
|
||||
<button id="confirm-ok" class="btn btn-sm">Confirm</button>
|
||||
</div>
|
||||
<form method="dialog" class="dialog-actions">
|
||||
<button value="cancel" class="btn-outline btn-sm">Cancel</button>
|
||||
<button value="ok" class="btn btn-sm">Confirm</button>
|
||||
</form>
|
||||
</dialog>
|
||||
<script>
|
||||
function confirmAction(message, form) {
|
||||
function showConfirm(message) {
|
||||
var dialog = document.getElementById('confirm-dialog');
|
||||
document.getElementById('confirm-msg').textContent = message;
|
||||
var ok = document.getElementById('confirm-ok');
|
||||
var newOk = ok.cloneNode(true);
|
||||
ok.replaceWith(newOk);
|
||||
newOk.addEventListener('click', function() { dialog.close(); form.submit(); });
|
||||
document.getElementById('confirm-cancel').addEventListener('click', function() { dialog.close(); }, { once: true });
|
||||
dialog.showModal();
|
||||
return new Promise(function(resolve) {
|
||||
dialog.addEventListener('close', function() {
|
||||
resolve(dialog.returnValue === 'ok');
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
document.body.addEventListener('htmx:confirm', function(evt) {
|
||||
if (!evt.detail.question) return;
|
||||
evt.preventDefault();
|
||||
showConfirm(evt.detail.question).then(function(ok) {
|
||||
if (ok) evt.detail.issueRequest(true);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<p class="text-slate text-sm">No data rows found. Run the data pipeline to populate <code>{{ config_data.data_table }}</code>.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" class="card">
|
||||
<form method="post" class="card" hx-boost="true">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-4">
|
||||
@@ -45,7 +45,8 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn" style="width: 100%;" onclick="confirmAction('Generate articles? Existing articles will be updated in-place.', this.closest('form'))">
|
||||
<button type="submit" class="btn" style="width: 100%;"
|
||||
hx-confirm="Generate articles? Existing articles will be updated in-place.">
|
||||
Generate Articles
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -21,10 +21,10 @@
|
||||
</td>
|
||||
<td class="text-right" style="white-space:nowrap">
|
||||
<a href="{{ url_for('admin.affiliate_program_edit', program_id=prog.id) }}" class="btn-outline btn-sm">Edit</a>
|
||||
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=prog.id) }}" style="display:inline">
|
||||
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=prog.id) }}" style="display:inline" hx-boost="true">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline btn-sm"
|
||||
onclick="return confirm('Delete {{ prog.name }}? This is blocked if products reference it.')">Delete</button>
|
||||
hx-confirm="Delete {{ prog.name }}? This is blocked if products reference it.">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
<td class="mono text-right">{{ product.click_count or 0 }}</td>
|
||||
<td class="text-right" style="white-space:nowrap">
|
||||
<a href="{{ url_for('admin.affiliate_edit', product_id=product.id) }}" class="btn-outline btn-sm">Edit</a>
|
||||
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product.id) }}" style="display:inline">
|
||||
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product.id) }}" style="display:inline" hx-boost="true">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline btn-sm"
|
||||
onclick="return confirm('Delete {{ product.name }}?')">Delete</button>
|
||||
hx-confirm="Delete {{ product.name }}?">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -29,10 +29,10 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{ url_for('pipeline.pipeline_trigger_extract') }}" class="m-0">
|
||||
<form method="post" action="{{ url_for('pipeline.pipeline_trigger_extract') }}" class="m-0" hx-boost="true">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="button" class="btn-outline btn-sm"
|
||||
onclick="confirmAction('Enqueue a full extraction run? This will run all extractors in the background.', this.closest('form'))">
|
||||
<button type="submit" class="btn-outline btn-sm"
|
||||
hx-confirm="Enqueue a full extraction run? This will run all extractors in the background.">
|
||||
Run All Extractors
|
||||
</button>
|
||||
</form>
|
||||
@@ -112,11 +112,11 @@
|
||||
{% if run.status == 'running' %}
|
||||
<form method="post"
|
||||
action="{{ url_for('pipeline.pipeline_mark_stale', run_id=run.run_id) }}"
|
||||
class="m-0">
|
||||
class="m-0" hx-boost="true">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="button" class="btn-danger btn-sm"
|
||||
<button type="submit" class="btn-danger btn-sm"
|
||||
style="padding:2px 8px;font-size:11px"
|
||||
onclick="confirmAction('Mark run #{{ run.run_id }} as failed? Only do this if the process is definitely dead.', this.closest('form'))">
|
||||
hx-confirm="Mark run #{{ run.run_id }} as failed? Only do this if the process is definitely dead.">
|
||||
Mark Failed
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
hx-target="#pipeline-overview-content"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"extractor": "{{ wf.name }}", "csrf_token": "{{ csrf_token() }}"}'
|
||||
onclick="if (!confirm('Run {{ wf.name }} extractor?')) return false;">Run</button>
|
||||
hx-confirm="Run {{ wf.name }} extractor?">Run</button>
|
||||
</div>
|
||||
<p class="text-xs text-slate">{{ wf.schedule_label }}</p>
|
||||
{% if run %}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
hx-target="#pipeline-transform-content"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"step": "transform", "csrf_token": "{{ csrf_token() }}"}'
|
||||
onclick="if (!confirm('Run SQLMesh transform (prod --auto-apply)?')) return false;">
|
||||
hx-confirm="Run SQLMesh transform (prod --auto-apply)?">
|
||||
Run Transform
|
||||
</button>
|
||||
</div>
|
||||
@@ -107,7 +107,7 @@
|
||||
hx-target="#pipeline-transform-content"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"step": "export", "csrf_token": "{{ csrf_token() }}"}'
|
||||
onclick="if (!confirm('Export serving tables (lakehouse → analytics.duckdb)?')) return false;">
|
||||
hx-confirm="Export serving tables (lakehouse → analytics.duckdb)?">
|
||||
Run Export
|
||||
</button>
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@
|
||||
hx-target="#pipeline-transform-content"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"step": "pipeline", "csrf_token": "{{ csrf_token() }}"}'
|
||||
onclick="if (!confirm('Run full ELT pipeline (extract → transform → export)?')) return false;">
|
||||
hx-confirm="Run full ELT pipeline (extract → transform → export)?">
|
||||
Run Full Pipeline
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -36,9 +36,10 @@
|
||||
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='en') }}" class="btn-outline btn-sm">PDF EN</a>
|
||||
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='de') }}" class="btn-outline btn-sm">PDF DE</a>
|
||||
<a href="{{ url_for('admin.scenario_edit', scenario_id=s.id) }}" class="btn-outline btn-sm">Edit</a>
|
||||
<form method="post" action="{{ url_for('admin.scenario_delete', scenario_id=s.id) }}" class="m-0" style="display: inline;">
|
||||
<form method="post" action="{{ url_for('admin.scenario_delete', scenario_id=s.id) }}" class="m-0" style="display: inline;" hx-boost="true">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="button" class="btn-outline btn-sm" onclick="confirmAction('Delete this scenario? This cannot be undone.', this.closest('form'))">Delete</button>
|
||||
<button type="submit" class="btn-outline btn-sm"
|
||||
hx-confirm="Delete this scenario? This cannot be undone.">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -56,11 +56,11 @@
|
||||
<p class="text-sm text-slate mt-1">Extraction status, data catalog, and ad-hoc query editor</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<form method="post" action="{{ url_for('pipeline.pipeline_trigger_transform') }}" class="m-0">
|
||||
<form method="post" action="{{ url_for('pipeline.pipeline_trigger_transform') }}" class="m-0" hx-boost="true">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="step" value="pipeline">
|
||||
<button type="button" class="btn btn-sm"
|
||||
onclick="confirmAction('Run full ELT pipeline (extract → transform → export)? This runs in the background.', this.closest('form'))">
|
||||
<button type="submit" class="btn btn-sm"
|
||||
hx-confirm="Run full ELT pipeline (extract → transform → export)? This runs in the background.">
|
||||
Run Pipeline
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('admin.template_generate', slug=config_data.slug) }}" class="btn">Generate Articles</a>
|
||||
<form method="post" action="{{ url_for('admin.template_regenerate', slug=config_data.slug) }}" style="display:inline">
|
||||
<form method="post" action="{{ url_for('admin.template_regenerate', slug=config_data.slug) }}" style="display:inline" hx-boost="true">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="button" class="btn-outline" onclick="confirmAction('Regenerate all articles for this template with fresh data? Existing articles will be overwritten.', this.closest('form'))">
|
||||
<button type="submit" class="btn-outline"
|
||||
hx-confirm="Regenerate all articles for this template with fresh data? Existing articles will be overwritten.">
|
||||
Regenerate
|
||||
</button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user