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:
parent
637f5a7073
commit
f661938820
5 changed files with 224 additions and 18 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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="👁 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="🔒 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>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue