merge: unify confirm dialog — pure hx-confirm + form[method=dialog]

Eliminates confirmAction() entirely. One code path: all confirmations
go through showConfirm() called by the htmx:confirm interceptor.
14 template files converted to hx-boost + hx-confirm pattern.
Pipeline endpoints updated to exclude HX-Boosted requests from the
HTMX partial path.

# Conflicts:
#	web/src/padelnomics/admin/templates/admin/affiliate_form.html
#	web/src/padelnomics/admin/templates/admin/affiliate_program_form.html
#	web/src/padelnomics/admin/templates/admin/base_admin.html
#	web/src/padelnomics/admin/templates/admin/partials/affiliate_program_results.html
#	web/src/padelnomics/admin/templates/admin/partials/affiliate_row.html
This commit is contained in:
Deeman
2026-03-02 07:48:49 +01:00
14 changed files with 54 additions and 51 deletions

View File

@@ -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()

View File

@@ -226,10 +226,9 @@ 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="event.preventDefault(); confirmAction('Delete this product? This cannot be undone.', this.closest('form'))">Delete</button>
</form>
{% endif %}
</div>

View File

@@ -120,10 +120,9 @@ 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="event.preventDefault(); confirmAction('Delete this program? Blocked if products reference it.', this.closest('form'))">Delete</button>
</form>
{% endif %}
</div>

View File

@@ -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>

View File

@@ -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>

View File

@@ -228,36 +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 });
});
}
// Intercept hx-confirm to use the styled dialog instead of window.confirm()
document.body.addEventListener('htmx:confirm', function(evt) {
if (!evt.detail.question) return; // no hx-confirm on this element, let HTMX proceed
var dialog = document.getElementById('confirm-dialog');
if (!dialog) return; // fallback: let HTMX use native confirm
if (!evt.detail.question) return;
evt.preventDefault();
document.getElementById('confirm-msg').textContent = evt.detail.question;
var ok = document.getElementById('confirm-ok');
var newOk = ok.cloneNode(true);
ok.replaceWith(newOk);
newOk.addEventListener('click', function() { dialog.close(); evt.detail.issueRequest(true); }, { once: true });
document.getElementById('confirm-cancel').addEventListener('click', function() { dialog.close(); }, { once: true });
dialog.showModal();
showConfirm(evt.detail.question).then(function(ok) {
if (ok) evt.detail.issueRequest(true);
});
});
</script>
{% endblock %}

View File

@@ -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>

View File

@@ -21,10 +21,9 @@
</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="event.preventDefault(); confirmAction('Delete {{ prog.name }}? This is blocked if products reference it.', this.closest('form'))">Delete</button>
</form>
</td>
</tr>

View File

@@ -20,10 +20,9 @@
<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="event.preventDefault(); confirmAction('Delete {{ product.name }}?', this.closest('form'))">Delete</button>
</form>
</td>
</tr>

View File

@@ -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>

View File

@@ -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>

View File

@@ -57,11 +57,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>

View File

@@ -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>