AJAX progressive enhancement for all action buttons.

Lock/unlock, watch/unwatch, disable/enable, verify, approve/deny
now return JSON for XHR requests and swap client-side without a
page reload. Falls back to normal POST + redirect when JS is off.
This commit is contained in:
russell@unturf.com 2026-02-08 08:38:27 -05:00
parent 637f5a7073
commit f661938820
5 changed files with 224 additions and 18 deletions

View file

@ -394,8 +394,167 @@ function insertReply(data, form) {
}
}
// Wire up AJAX on any new reply forms.
// Wire up AJAX on any new reply forms and action buttons.
initAjaxCommentForms();
initAjaxActionForms();
}
// AJAX action buttons — capability-driven presentation.
// When JS is available, intercepts action form POSTs (lock, unlock, watch,
// unwatch, disable, enable, verify, approve, deny) and submits via fetch
// so the page does not reload. Falls back to normal POST + redirect when
// JS is disabled or on error.
function initAjaxActionForms() {
document.querySelectorAll('form.ajax-action').forEach(function(form) {
if (form.dataset.ajaxBound) return;
form.dataset.ajaxBound = '1';
form.addEventListener('submit', function(e) {
var btn = form.querySelector('[type="submit"]');
if (!btn) return;
e.preventDefault();
var formData = new FormData(form);
btn.disabled = true;
fetch(form.action, {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(response) {
if (!response.ok) {
btn.disabled = false;
form.submit();
return;
}
return response.json();
})
.then(function(data) {
if (!data) return;
handleActionResponse(data, form, btn);
})
.catch(function() {
btn.disabled = false;
form.submit();
});
});
});
}
function handleActionResponse(data, form, btn) {
if (!data.ok) {
btn.disabled = false;
form.submit();
return;
}
var action = form.action;
// Top-level toggle pairs: /lock ↔ /unlock, /watch ↔ /unwatch
if (action.match(/\/lock$/)) {
form.action = action.replace(/\/lock$/, '/unlock');
btn.value = 'unlock';
btn.name = 'unlock';
btn.className = 'unlock button-small';
btn.disabled = false;
return;
}
if (action.match(/\/unlock$/)) {
form.action = action.replace(/\/unlock$/, '/lock');
btn.value = '\uD83D\uDD12 lock';
btn.name = 'lock';
btn.className = 'lock button-small';
btn.disabled = false;
return;
}
if (action.match(/\/watch$/)) {
form.action = action.replace(/\/watch$/, '/unwatch');
btn.value = 'unwatch';
btn.name = 'unwatch';
btn.className = 'unwatch button-small';
btn.disabled = false;
return;
}
if (action.match(/\/unwatch$/)) {
form.action = action.replace(/\/unwatch$/, '/watch');
btn.value = '\uD83D\uDC41 watch';
btn.name = 'watch';
btn.className = 'watch button-small';
btn.disabled = false;
return;
}
// Node-level toggle pairs: .../disable ↔ .../enable
if (action.match(/\/disable$/)) {
form.action = action.replace(/\/disable$/, '/enable');
btn.textContent = 'enable';
btn.name = 'enable';
btn.value = 'enable';
btn.disabled = false;
// Visually mark the node as disabled.
var nodeDiv = form.closest('.node');
if (nodeDiv) {
var statusSpan = nodeDiv.querySelector('.status');
if (statusSpan) statusSpan.innerHTML = '<span>(waiting for deletion)</span>';
var authorDate = nodeDiv.querySelector('.author-and-date');
if (authorDate) authorDate.innerHTML = 'node was disabled';
}
return;
}
if (action.match(/\/enable$/)) {
form.action = action.replace(/\/enable$/, '/disable');
btn.textContent = 'disable';
btn.name = 'disable';
btn.value = 'disable';
btn.disabled = false;
// Reload to restore full node content since we don't have it client-side.
window.location.reload();
return;
}
// .../approve ↔ .../deny
if (action.match(/\/approve$/)) {
form.action = action.replace(/\/approve$/, '/deny');
btn.textContent = 'deny';
btn.name = 'deny';
btn.value = 'deny';
btn.disabled = false;
// Remove the adjacent deny button if present (from the "both" state).
var sibling = form.nextElementSibling;
while (sibling && !sibling.matches('form.ajax-action')) {
sibling = sibling.nextElementSibling;
}
if (sibling && sibling.querySelector('[name="deny"]')) {
sibling.remove();
}
return;
}
if (action.match(/\/deny$/)) {
form.action = action.replace(/\/deny$/, '/approve');
btn.textContent = 'approve';
btn.name = 'approve';
btn.value = 'approve';
btn.disabled = false;
// Remove the adjacent approve button if present (from the "both" state).
var sibling = form.previousElementSibling;
while (sibling && !sibling.matches('form.ajax-action')) {
sibling = sibling.previousElementSibling;
}
if (sibling && sibling.querySelector('[name="approve"]')) {
sibling.remove();
}
return;
}
// .../verify — no toggle, just remove the button.
if (action.match(/\/verify$/)) {
form.remove();
return;
}
// Unknown action — re-enable and let normal flow handle it.
btn.disabled = false;
}
// Initialize on DOM ready
@ -418,6 +577,9 @@ document.addEventListener('DOMContentLoaded', function() {
// Initialize AJAX comment forms (capability-driven presentation).
initAjaxCommentForms();
// Initialize AJAX action buttons (capability-driven presentation).
initAjaxActionForms();
// Vote button handlers
document.querySelectorAll('button.vote-up').forEach(function(btn) {
btn.addEventListener('click', function() {

View file

@ -137,7 +137,7 @@
{% endmacro %}
{% macro watch_node(node_id) %}
<form action="/watch" id="watch" method="POST" onsubmit="submit.disabled = true; return true;" style="display: inline;">
<form action="/watch" id="watch" method="POST" class="ajax-action" onsubmit="submit.disabled = true; return true;" style="display: inline;">
{% include 'csrf.j2' %}
<input type="hidden" name="root-id" value="{{ node_id }}">
<input type="submit" name="watch" id="submit" class="watch button-small" value="&#128065; watch" />
@ -145,7 +145,7 @@
{% endmacro %}
{% macro unwatch_node(node_id="", watcher_id="") %}
<form action="/unwatch" id="unwatch" method="POST" onsubmit="submit.disabled = true; return true;" style="display: inline;">
<form action="/unwatch" id="unwatch" method="POST" class="ajax-action" onsubmit="submit.disabled = true; return true;" style="display: inline;">
{% include 'csrf.j2' %}
<input type="hidden" name="root-id" value="{{ node_id }}">
<input type="hidden" name="watcher-id" value="{{ watcher_id }}">
@ -154,7 +154,7 @@
{% endmacro %}
{% macro lock_node(node_id) %}
<form action="/lock" id="lock" method="POST" onsubmit="submit.disabled = true; return true;" style="display: inline;">
<form action="/lock" id="lock" method="POST" class="ajax-action" onsubmit="submit.disabled = true; return true;" style="display: inline;">
{% include 'csrf.j2' %}
<input type="hidden" name="root-id" value="{{ node_id }}">
<input type="submit" name="lock" id="submit" class="lock button-small" value="&#128274; lock" />
@ -162,7 +162,7 @@
{% endmacro %}
{% macro unlock_node(node_id) %}
<form action="/unlock" id="unlock" method="POST" onsubmit="submit.disabled = true; return true;" style="display: inline;">
<form action="/unlock" id="unlock" method="POST" class="ajax-action" onsubmit="submit.disabled = true; return true;" style="display: inline;">
{% include 'csrf.j2' %}
<input type="hidden" name="root-id" value="{{ node_id }}">
<input type="submit" name="unlock" id="submit" class="unlock button-small" value="unlock" />
@ -171,35 +171,35 @@
{% macro disable_node(node) %}
<form action="{{ request.link_prefix }}/{{ node.id }}/disable" method="POST" class="node-action" onsubmit="submit.disabled = true; return true;">
<form action="{{ request.link_prefix }}/{{ node.id }}/disable" method="POST" class="node-action ajax-action" onsubmit="submit.disabled = true; return true;">
{% include 'csrf.j2' %}
<button type="submit" name="disable" id="submit" class="action link" value="disable">disable</button>
</form>
{% endmacro %}
{% macro enable_node(node) %}
<form action="{{ request.link_prefix }}/{{ node.id }}/enable" method="POST" class="node-action" onsubmit="submit.disabled = true; return true;">
<form action="{{ request.link_prefix }}/{{ node.id }}/enable" method="POST" class="node-action ajax-action" onsubmit="submit.disabled = true; return true;">
{% include 'csrf.j2' %}
<button type="submit" name="enable" id="submit" class="action link" value="enable">enable</button>
</form>
{% endmacro %}
{% macro verify_node(node) %}
<form action="{{ request.link_prefix }}/{{ node.id }}/verify" method="POST" class="node-action" onsubmit="submit.disabled = true; return true;">
<form action="{{ request.link_prefix }}/{{ node.id }}/verify" method="POST" class="node-action ajax-action" onsubmit="submit.disabled = true; return true;">
{% include 'csrf.j2' %}
<button type="submit" name="verify" id="submit" class="action link" value="verify">verify</button>
</form>
{% endmacro %}
{% macro approve_node(node) %}
<form action="{{ request.link_prefix }}/{{ node.id }}/approve" method="POST" class="node-action" onsubmit="submit.disabled = true; return true;">
<form action="{{ request.link_prefix }}/{{ node.id }}/approve" method="POST" class="node-action ajax-action" onsubmit="submit.disabled = true; return true;">
{% include 'csrf.j2' %}
<button type="submit" name="approve" id="submit" class="action link" value="approve">approve</button>
</form>
{% endmacro %}
{% macro deny_node(node) %}
<form action="{{ request.link_prefix }}/{{ node.id }}/deny" method="POST" class="node-action" onsubmit="submit.disabled = true; return true;">
<form action="{{ request.link_prefix }}/{{ node.id }}/deny" method="POST" class="node-action ajax-action" onsubmit="submit.disabled = true; return true;">
{% include 'csrf.j2' %}
<button type="submit" name="deny" id="submit" class="action link" value="deny">deny</button>
</form>

View file

@ -1,6 +1,7 @@
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound
from pyramid.response import Response
from remarkbox.models import get_node_by_id
@ -14,10 +15,13 @@ log = logging.getLogger(__name__)
@view_config(route_name="lock", request_method=("POST", "PUT"))
@user_required()
def lock(request):
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
root_id = request.params.get("root-id", None)
root = get_node_by_id(request.dbsession, root_id)
if not root:
if is_ajax:
return Response(json={"ok": False, "error": "Thread does not exist."}, status=404)
request.session.flash(
(
"Thread does not exist (root-id={}).".format(root_id),
@ -28,6 +32,8 @@ def lock(request):
root.locked = True
request.dbsession.add(root)
request.dbsession.flush()
if is_ajax:
return Response(json={"ok": True, "action": "locked"}, status=200)
request.session.flash(
(
"You <b>locked</b> this thread to prevent new comments.",
@ -43,6 +49,8 @@ def lock(request):
)
)
else:
if is_ajax:
return Response(json={"ok": False, "error": "Namespace moderator role needed."}, status=403)
request.session.flash(
(
"You may <ul>not</ul> lock this thread. Namespace moderator role needed. ({}).".format(root.namespace.name),
@ -56,10 +64,13 @@ def lock(request):
@view_config(route_name="unlock", request_method=("POST", "PUT"))
@user_required()
def unlock(request):
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
root_id = request.params.get("root-id", None)
root = get_node_by_id(request.dbsession, root_id)
if not root:
if is_ajax:
return Response(json={"ok": False, "error": "Thread does not exist."}, status=404)
request.session.flash(
(
"Thread does not exist (root-id={}).".format(root_id),
@ -70,6 +81,8 @@ def unlock(request):
root.locked = False
request.dbsession.add(root)
request.dbsession.flush()
if is_ajax:
return Response(json={"ok": True, "action": "unlocked"}, status=200)
request.session.flash(
(
"You <b>unlocked</b> this thread to allow new comments.",
@ -85,11 +98,13 @@ def unlock(request):
)
)
else:
if is_ajax:
return Response(json={"ok": False, "error": "Namespace moderator role needed."}, status=403)
request.session.flash(
(
"You may <ul>not</ul> unlock this thread. Namespace moderator role needed. ({}).".format(root.namespace.name),
"error",
)
)
return HTTPFound(get_referer_or_home(request))

View file

@ -1,23 +1,24 @@
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound
from pyramid.response import Response
from remarkbox.models import get_node_by_id
from remarkbox.views import get_referer_or_home, user_required
# TODO: maybe use this when javascript is enabled.
# @view_config(route_name="watch", renderer="json", request_method=("POST", "PUT"), xhr=True)
# otherwise fallback to this when javascript is disabled:
@view_config(route_name="watch", request_method=("POST", "PUT"))
@user_required()
def watch(request):
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
root_id = request.params.get("root-id", None)
if root_id:
root = get_node_by_id(request.dbsession, root_id)
if root:
watcher = request.user.watch_node(root)
if is_ajax:
return Response(json={"ok": True, "action": "watched"}, status=200)
request.session.flash(
(
"You <b>watched</b> this thread. We will notify you of changes <b>{}</b>.".format(
@ -27,6 +28,8 @@ def watch(request):
)
)
else:
if is_ajax:
return Response(json={"ok": False, "error": "Thread does not exist."}, status=404)
request.session.flash(
("You <b>may not</b> watch an empty thread.", "error")
)
@ -34,12 +37,10 @@ def watch(request):
return HTTPFound(get_referer_or_home(request))
# TODO: maybe use this when javascript is enabled.
# @view_config(route_name="watch", renderer="json", request_method=("POST", "PUT"), xhr=True)
# otherwise fallback to this when javascript is disabled:
@view_config(route_name="unwatch", request_method=("POST", "PUT"))
@user_required()
def unwatch(request):
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
root_id = request.params.get("root-id", None)
watcher_id = request.params.get("watcher-id", None)
@ -50,6 +51,8 @@ def unwatch(request):
if watcher:
request.dbsession.delete(watcher)
if is_ajax:
return Response(json={"ok": True, "action": "unwatched"}, status=200)
request.session.flash(
(
"You <b>unwatched</b> this thread. We <b>will not notify</b> you of changes.",

View file

@ -1,6 +1,7 @@
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound
from pyramid.response import Response
from remarkbox.models.node import Node
@ -51,10 +52,15 @@ def _node_route_uri(request):
@view_config(route_name="basic-disable", request_method=("POST", "PUT"))
def disable_node(request):
"""disable node if user allowed."""
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
if request.namespace.can_alter_node(request.node, request.user):
request.node.disable()
request.dbsession.add(request.node)
request.dbsession.flush()
if is_ajax:
return Response(json={"ok": True, "action": "disabled"}, status=200)
elif is_ajax:
return Response(json={"ok": False, "error": "Not allowed."}, status=403)
return HTTPFound(_node_route_uri(request))
@ -62,10 +68,15 @@ def disable_node(request):
@view_config(route_name="basic-enable", request_method=("POST", "PUT"))
def enable_node(request):
"""enable node if user allowed."""
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
if request.namespace.can_alter_node(request.node, request.user):
request.node.enable()
request.dbsession.add(request.node)
request.dbsession.flush()
if is_ajax:
return Response(json={"ok": True, "action": "enabled"}, status=200)
elif is_ajax:
return Response(json={"ok": False, "error": "Not allowed."}, status=403)
return HTTPFound(_node_route_uri(request))
@ -73,10 +84,15 @@ def enable_node(request):
@view_config(route_name="basic-verify", request_method=("POST", "PUT"))
def verify_node(request):
"""only the node creator may verify ownership of a node."""
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
if request.node.user == request.user:
request.node.verify()
request.dbsession.add(request.node)
request.dbsession.flush()
if is_ajax:
return Response(json={"ok": True, "action": "verified"}, status=200)
elif is_ajax:
return Response(json={"ok": False, "error": "Not allowed."}, status=403)
return HTTPFound(_node_route_uri(request))
@ -84,10 +100,15 @@ def verify_node(request):
@view_config(route_name="basic-approve", request_method=("POST", "PUT"))
def approve_node(request):
"""approve node if user allowed."""
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
if request.namespace.is_moderator(request.user):
request.node.approve()
request.dbsession.add(request.node)
request.dbsession.flush()
if is_ajax:
return Response(json={"ok": True, "action": "approved"}, status=200)
elif is_ajax:
return Response(json={"ok": False, "error": "Moderator role needed."}, status=403)
return HTTPFound(_node_route_uri(request))
@ -95,10 +116,15 @@ def approve_node(request):
@view_config(route_name="basic-deny", request_method=("POST", "PUT"))
def deny_node(request):
"""deny node if user allowed."""
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
if request.namespace.is_moderator(request.user):
request.node.deny()
request.dbsession.add(request.node)
request.dbsession.flush()
if is_ajax:
return Response(json={"ok": True, "action": "denied"}, status=200)
elif is_ajax:
return Response(json={"ok": False, "error": "Moderator role needed."}, status=403)
return HTTPFound(_node_route_uri(request))