Skip to content

Commit

Permalink
Cut and paste based interface for moving pages
Browse files Browse the repository at this point in the history
  • Loading branch information
matthiask committed Jun 2, 2024
1 parent 5f7d1b3 commit 4eff6bf
Show file tree
Hide file tree
Showing 5 changed files with 259 additions and 73 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Next version
scenarios to allow the root middleware to run even after views return a 404
response.
- Switched from ESLint to biome.
- Changed the move node interface to a cut-paste based interface which works
directly in the admin changelist.


4.6 (2024-02-26)
Expand Down
121 changes: 115 additions & 6 deletions feincms3/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
from django.contrib.admin import ModelAdmin, SimpleListFilter, display, helpers
from django.contrib.admin.options import IncorrectLookupParameters, csrf_protect_m
from django.contrib.admin.utils import unquote
from django.core.exceptions import PermissionDenied
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import router, transaction
from django.db.models import F
from django.http import HttpResponse
from django.shortcuts import redirect
from django.urls import re_path, reverse
from django.urls import path, re_path, reverse
from django.utils.html import format_html, mark_safe
from django.utils.text import capfirst
from django.utils.translation import gettext_lazy as _
Expand Down Expand Up @@ -131,6 +133,31 @@ def move_column(self, instance):
Show a ``move`` link which leads to a separate page where the move
destination may be selected.
"""
return format_html(
"""\
<div class="move-controls">
<button class="move-cut" type="button" data-pk="{}" title="{}">
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor"><path d="M760-120 480-400l-94 94q8 15 11 32t3 34q0 66-47 113T240-80q-66 0-113-47T80-240q0-66 47-113t113-47q17 0 34 3t32 11l94-94-94-94q-15 8-32 11t-34 3q-66 0-113-47T80-720q0-66 47-113t113-47q66 0 113 47t47 113q0 17-3 34t-11 32l494 494v40H760ZM600-520l-80-80 240-240h120v40L600-520ZM240-640q33 0 56.5-23.5T320-720q0-33-23.5-56.5T240-800q-33 0-56.5 23.5T160-720q0 33 23.5 56.5T240-640Zm240 180q8 0 14-6t6-14q0-8-6-14t-14-6q-8 0-14 6t-6 14q0 8 6 14t14 6ZM240-160q33 0 56.5-23.5T320-240q0-33-23.5-56.5T240-320q-33 0-56.5 23.5T160-240q0 33 23.5 56.5T240-160Z"/></svg>
</button>
<select class="move-paste" data-pk="{}">
<option value="">{}</option>
<option value="before">{}</option> -->
<option value="first-child">{}</option>
<option value="last-child">{}</option>
<option value="after">{}</option>
</select>
</div>
""",
instance.pk,
_("Move '{}' to a new location").format(instance),
instance.pk,
_("move here"),
_("before"),
_("as first child"),
_("as last child"),
_("after"),
)

opts = self.model._meta
return format_html(
'<a href="{}">{}</a>',
Expand All @@ -141,7 +168,7 @@ def move_column(self, instance):
_("move"),
)

move_column.short_description = ""
move_column.short_description = _("move")

def get_urls(self):
"""
Expand All @@ -150,6 +177,10 @@ def get_urls(self):

info = self.model._meta.app_label, self.model._meta.model_name
return [
path(
"move-node/",
self.admin_site.admin_view(self.move_node_view),
),
re_path(
r"^(.+)/move/$",
action_form_view_decorator(self)(self.move_view),
Expand All @@ -162,6 +193,11 @@ def get_urls(self):
),
] + super().get_urls()

def move_node_view(self, request):
kw = {"request": request, "modeladmin": self}
form = MoveNodeForm(request.POST, **kw)
return HttpResponse(form.process())

def move_view(self, request, obj):
return self.action_form_view(
request, obj, form_class=MoveForm, title=_("Move %s") % obj
Expand Down Expand Up @@ -217,6 +253,82 @@ def render_action_form(self, request, form, *, title, obj):
return response


class MoveNodeForm(forms.Form):
def __init__(self, *args, **kwargs):
self.modeladmin = kwargs.pop("modeladmin")
self.request = kwargs.pop("request")
super().__init__(*args, **kwargs)

self.fields["move"] = forms.ModelChoiceField(
queryset=self.modeladmin.get_queryset(self.request)
)
self.fields["relative_to"] = forms.ModelChoiceField(
queryset=self.modeladmin.get_queryset(self.request)
)
positions = ("before", "first-child", "last-child", "after")
self.fields["position"] = forms.ChoiceField(choices=zip(positions, positions))

def process(self):
if not self.is_valid():
messages.error(self.request, _("Invalid node move request."))
messages.error(self.request, str(self.errors))
return "error"

move = self.cleaned_data["move"]
relative_to = self.cleaned_data["relative_to"]
position = self.cleaned_data["position"]

print(self.cleaned_data)

if position in {"first-child", "last-child"}:
move.parent = relative_to
siblings_qs = relative_to.children
else:
move.parent = relative_to.parent
siblings_qs = relative_to.__class__._default_manager.filter(
parent=relative_to.parent
)

try:
# All fields of model are not in this form
move.full_clean(exclude=[f.name for f in move._meta.get_fields()])
except ValidationError as exc:
messages.error(self.request, _("Error while validating the new position."))
messages.error(self.request, str(exc))
return "error"

if position == "before":
siblings_qs.filter(position__gte=relative_to.position).update(
position=F("position") + 10
)
move.position = relative_to.position
move.save()

elif position == "after":
siblings_qs.filter(position__gt=relative_to.position).update(
position=F("position") + 10
)
move.position = relative_to.position + 10
move.save()

elif position == "first-child":
siblings_qs.update(position=F("position") + 10)
move.position = 10
move.save()

elif position == "last-child":
move.position = 0 # Let AbstractPage.save handle the position
move.save()

else: # pragma: no cover
pass

messages.success(
self.request, _("Node {} has been moved to its new position.").format(move)
)
return "ok"


class MoveForm(forms.Form):
"""
Allows making the node the left or right sibling or the first or last
Expand All @@ -225,9 +337,6 @@ class MoveForm(forms.Form):
Requires the node to be moved as ``obj`` keyword argument.
"""

class Media:
css = {"screen": ["feincms3/move-form.css"]}

def __init__(self, *args, **kwargs):
self.instance = kwargs.pop("obj")
self.modeladmin = kwargs.pop("modeladmin")
Expand Down
49 changes: 49 additions & 0 deletions feincms3/static/feincms3/box-drawing.css
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,52 @@
.collapse-hide {
display: none !important;
}

.move-status {
background: var(--selected-row);
padding: 8px 16px;
cursor: pointer;
position: fixed;
left: 0;
bottom: 0;
border: 1px solid var(--hairline-color);
}

.field-move_column {
padding: 0;
vertical-align: middle;
}

.move-controls {
display: flex;
gap: 4px;
align-items: center;
}

.move-cut {
appearance: none;
cursor: pointer;
background: none;
border: none;
padding: 0;
color: var(--link-fg);
display: inline-grid;
place-items: center;
}

.moving .move-selected .move-paste {
display: none;
}

#changelist tbody tr.move-selected {
background: var(--selected-row);
}

.move-paste {
height: auto;
display: none;
}

.moving .move-paste {
display: revert;
}
93 changes: 93 additions & 0 deletions feincms3/static/feincms3/box-drawing.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,96 @@ document.addEventListener("DOMContentLoaded", () => {
)
initiallyCollapse(context.initiallyCollapseDepth)
})

document.addEventListener("DOMContentLoaded", () => {
let statusElement
const showMoving = (moving) => {
if (!statusElement) {
statusElement = document.createElement("div")
statusElement.className = "move-status"
document.body.append(statusElement)
}

for (const el of document.querySelectorAll(".move-selected"))
el.classList.remove("move-selected")

if (moving) {
statusElement.textContent = `${moving.title} (click to cancel)`
statusElement.style.display = "block"
document.body.classList.add("moving")

document
.querySelector(`[data-pk="${moving.pk}"]`)
.closest("tr")
.classList.add("move-selected")
} else {
statusElement.style.display = "none"
document.body.classList.remove("moving")
}
}

document.addEventListener("click", (e) => {
const btn = e.target.closest(".move-cut")
if (btn) {
setMoving(
_moving?.pk === btn.dataset.pk
? null
: { pk: btn.dataset.pk, title: btn.title },
)
}

const el = e.target.closest(".move-status")
if (el) {
setMoving(null)
}
})

document.addEventListener("change", (e) => {
const select = e.target.closest(".move-paste")
if (select?.value && _moving) {
const csrf = document.querySelector(
"input[name=csrfmiddlewaretoken]",
).value
const body = new FormData()
body.append("csrfmiddlewaretoken", csrf)
body.append("move", _moving.pk)
body.append("relative_to", select.dataset.pk)
body.append("position", select.value)

fetch("move-node/", {
credentials: "include",
method: "POST",
body,
}).then(() => {
setMoving(null)
window.location.reload()
})

// console.debug(JSON.stringify({ _moving, where: `${select.dataset.pk}:${select.value}` }))
}
})

document.body.addEventListener("keyup", (e) => {
if (e.key === "Escape") setMoving(null)
})

const _key = `f3moving:${location.pathname}`
let _moving
try {
_moving = JSON.parse(sessionStorage.getItem(_key))
} catch (e) {
console.error(e)
}

const setMoving = (moving) => {
_moving = moving
if (_moving) {
sessionStorage.setItem(_key, JSON.stringify(_moving))
} else {
sessionStorage.removeItem(_key)
}
showMoving(_moving)
}

showMoving(_moving)
})
67 changes: 0 additions & 67 deletions feincms3/static/feincms3/move-form.css

This file was deleted.

0 comments on commit 4eff6bf

Please sign in to comment.