Last active
September 15, 2021 16:19
-
-
Save cnk/ba0c200848a09605ee971c9b4c420255 to your computer and use it in GitHub Desktop.
Example of drag and drop sorting of a category hierarchy
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from django import forms | |
from django.conf import settings | |
from django.urls import re_path | |
from django.contrib.admin.utils import quote, unquote | |
from django.core.exceptions import PermissionDenied | |
from django.core.validators import MinLengthValidator | |
from django.db import models | |
from django.http import JsonResponse, HttpResponseNotAllowed | |
from django.shortcuts import get_object_or_404 | |
from django.template.loader import render_to_string | |
from django.utils.safestring import mark_safe | |
from treebeard.mp_tree import MP_Node | |
from wagtail.contrib.modeladmin.helpers import ButtonHelper | |
from wagtail.contrib.modeladmin.options import ModelAdmin | |
from wagtail.contrib.modeladmin.views import CreateView | |
from wagtail.admin.edit_handlers import FieldPanel | |
from wagtail.core import hooks | |
from wagtail.core.fields import RichTextField | |
from wagtail.core.models import Site | |
from wagtail.images.edit_handlers import ImageChooserPanel | |
from wagtail.search import index | |
from core.logging import logger | |
from core.models.utils import limit_to_current_site | |
from core.utils import ( | |
SiteSpecificModelForm, | |
get_current_request2, | |
WagtailSubmenuRegisterable, | |
grant_permissions_for_model_to_group) | |
class PersonCategory(MP_Node, index.Indexed): | |
"""Represents a single nestable PersonCategory in the taxonomy.""" | |
# editable fields | |
site = models.ForeignKey( | |
Site, | |
null=True, | |
blank=True, | |
on_delete=models.CASCADE | |
) | |
name = models.CharField( | |
max_length=255, | |
help_text='Name that will be displayed in navigation and (optionally) on people pages', | |
validators=[MinLengthValidator(5)] | |
) | |
url = models.URLField( | |
max_length=512, | |
blank=True, | |
help_text='Optional URL to create links from lists of categories to a relevant page.', | |
) | |
description = RichTextField( | |
editor='barebones', | |
blank=True, | |
help_text='Description that may appear when displaying lists of people in this category' | |
) | |
icon = models.ForeignKey( | |
'core.CaltechImage', | |
verbose_name="Image that may appear next to name when displaying lists of people in this category", | |
null=True, | |
blank=True, | |
on_delete=models.SET_NULL, | |
related_name='+' | |
) | |
# tree specific fields and attributes | |
node_child_verbose_name = 'child' | |
# wagtail specific - simple way to declare which fields are editable | |
panels = [ | |
FieldPanel('parent'), # virtual field - see TopicForm later | |
FieldPanel('name'), | |
FieldPanel('url'), | |
FieldPanel('description'), | |
ImageChooserPanel('icon'), | |
] | |
# CNK TODO do we want to add categories to the search index? | |
search_fields = [ | |
index.SearchField('name', partial_match=True), | |
index.SearchField('description', partial_match=True), | |
index.FilterField('site'), | |
] | |
class Meta: | |
unique_together = ["site", "name"] | |
verbose_name = 'Person Category' | |
verbose_name_plural = 'Person Categories' | |
def __str__(self): | |
return self.name | |
@staticmethod | |
def format_choices(categories): | |
""" | |
I want the nested categories to display indented by their depth so am adding space here. Giant kludge | |
but I got tired of pursuing how to to override setting the label in django.forms.models.ModelChoiceIterator | |
""" | |
choices = [] | |
for category in categories: | |
padding = ' ' * (category.depth - 2) * 5 | |
choices.append((category.id, mark_safe(f'{padding} {category.name}'))) | |
return choices | |
@staticmethod | |
def category_choices_for_site(): | |
""" | |
This needs to be a callable that takes no args so we can use it to define 'choices' in a ChoiceBlock | |
""" | |
request = get_current_request2('PersonCategory options list for site') | |
categories = PersonCategory.objects.filter(site_id=Site.find_for_request(request).id, depth__gt=1).all() | |
return PersonCategory.format_choices(categories) | |
@staticmethod | |
def category_types_for_site(): | |
""" | |
This needs to be a callable that takes no args so we can use it to define 'choices' in a ChoiceBlock | |
""" | |
request = get_current_request2('PersonCategory options list for site') | |
categories = PersonCategory.objects.filter(site_id=Site.find_for_request(request).id, depth=2).all() | |
return PersonCategory.format_choices(categories) | |
def delete(self): | |
"""Prevent users from deleting the root category.""" | |
if self.is_root(): | |
raise PermissionDenied('Cannot delete the root of the category hierarchy.') | |
else: | |
super().delete() | |
#### Adjust a few things in the ModelAdmin interface #### | |
@classmethod | |
def listing_queryset(cls, request): | |
return cls.objects.filter(site=Site.find_for_request(request)).all() | |
def get_as_listing_header(self): | |
""" | |
Build HTML representation of category with title & depth indication. | |
We subtract 1 from depth because we don't want the root of the tree to be draggable | |
""" | |
depth = self.get_depth() | |
rendered = render_to_string( | |
'caltech_sites/admin/person_category_list_header.html', | |
{ | |
'depth': depth - 1, | |
'is_root': self.is_root(), | |
'name': self.name, | |
} | |
) | |
return rendered | |
get_as_listing_header.short_description = 'Name' | |
get_as_listing_header.admin_order_field = 'name' | |
def get_parent(self, *args, **kwargs): | |
"""Duplicate of get_parent from treebeard API.""" | |
return super().get_parent(*args, **kwargs) | |
get_parent.short_description = 'Parent' | |
class ParentChoiceField(forms.ModelChoiceField): | |
def label_from_instance(self, obj): | |
depth_line = ' ' * (obj.get_depth() - 1) * 5 | |
return mark_safe("{} {}".format(depth_line, super().label_from_instance(obj))) | |
class PersonCategoryForm(SiteSpecificModelForm): | |
parent = ParentChoiceField( | |
required=True, | |
queryset=PersonCategory.objects.all(), | |
limit_choices_to=limit_to_current_site, | |
empty_label=None, | |
) | |
def __init__(self, *args, **kwargs): | |
super().__init__(*args, **kwargs) | |
instance = kwargs['instance'] | |
request = get_current_request2('PersonCategoryForm needs to restrict by site') | |
site = Site.find_for_request(request) | |
# Ensure this site has a root category | |
if not PersonCategory.objects.filter(site=site).exists(): | |
PersonCategory.add_root(site=site, name=f'{site.site_name} Root') | |
if instance.is_root(): | |
# hide and disable the parent field | |
self.fields['parent'].disabled = True | |
self.fields['parent'].required = False | |
self.fields['parent'].empty_label = 'Root Person Category' | |
self.fields['parent'].widget = forms.HiddenInput() | |
# update label to indicate this is the root | |
self.fields['name'].label += ' (Root)' | |
elif instance.id: | |
self.fields['parent'].initial = instance.get_parent() | |
def save(self, commit=True, *args, **kwargs): | |
instance = super().save(commit=False) | |
parent = self.cleaned_data['parent'] | |
if not commit: | |
# simply return the instance if not actually saving (committing) | |
return instance | |
if instance.id is None: # creating a new category | |
if PersonCategory.objects.all().count() == 0: # no categories, creating root | |
PersonCategory.add_root(instance=instance) # add a NEW root category | |
else: # categories exist, must be adding categories under a parent | |
instance = parent.add_child(instance=instance) | |
else: # editing an existing category | |
instance.save() # update existing category | |
if instance.get_parent() != parent: | |
instance.move(parent, pos='sorted-child') | |
return instance | |
PersonCategory.base_form_class = PersonCategoryForm | |
class PersonCategoryButtonHelper(ButtonHelper): | |
def delete_button(self, pk, *args, **kwargs): | |
"""Ensure that the delete button is not shown for the site's root category.""" | |
instance = self.model.objects.get(pk=pk) | |
if instance.is_root(): | |
return | |
return super().delete_button(pk, *args, **kwargs) | |
def prepare_classnames(self, start=None, add=None, exclude=None): | |
"""Parse classname sets into final css classess list.""" | |
classnames = start or [] | |
classnames.extend(add or []) | |
return self.finalise_classname(classnames, exclude or []) | |
def add_child_button(self, pk, child_verbose_name, **kwargs): | |
"""Build a add child button, to easily add a child under this category.""" | |
classnames = self.prepare_classnames( | |
start=self.edit_button_classnames + ['icon', 'icon-plus'], | |
add=kwargs.get('classnames_add'), | |
exclude=kwargs.get('classnames_exclude') | |
) | |
return { | |
'classname': classnames, | |
'label': 'Add %s %s' % ( | |
child_verbose_name, self.verbose_name), | |
'title': 'Add %s %s under this one' % ( | |
child_verbose_name, self.verbose_name), | |
'url': self.url_helper.get_action_url('add_child', quote(pk)), | |
} | |
def get_buttons_for_obj(self, obj, exclude=None, *args, **kwargs): | |
"""Override the getting of buttons, prepending create child button.""" | |
buttons = super().get_buttons_for_obj(obj, *args, **kwargs) | |
add_child_button = self.add_child_button( | |
pk=getattr(obj, self.opts.pk.attname), | |
child_verbose_name=getattr(obj, 'node_child_verbose_name'), | |
**kwargs | |
) | |
buttons.append(add_child_button) | |
return buttons | |
class AddChildCategoryViewClass(CreateView): | |
"""View class that can take an additional URL param for parent id.""" | |
parent_pk = None | |
parent_instance = None | |
def __init__(self, model_admin, parent_pk): | |
self.parent_pk = unquote(parent_pk) | |
object_qs = model_admin.model._default_manager.get_queryset() | |
object_qs = object_qs.filter(pk=self.parent_pk) | |
self.parent_instance = get_object_or_404(object_qs) | |
super().__init__(model_admin) | |
def get_page_title(self): | |
""" Generate a title that explains you are adding a child. """ | |
title = super().get_page_title() | |
return title + ' %s %s for %s' % ( | |
self.model.node_child_verbose_name, | |
self.opts.verbose_name, | |
self.parent_instance | |
) | |
def get_initial(self): | |
"""Set the selected parent field to the parent_pk.""" | |
return {'parent': self.parent_pk} | |
def move_category(request, instance_pk): | |
""" | |
Moves the specified category beneath the specfied destination, at the specified position. This function is used | |
to implement drag-and-drop page moving. | |
RETURNS {"status": "OK"} if everything goes well. | |
""" | |
cat = get_object_or_404(PersonCategory, id=instance_pk) | |
parent = get_object_or_404(PersonCategory, id=request.POST.get('destination_id')) | |
position = int(request.POST.get('position')) | |
if request.method != 'POST': | |
return HttpResponseNotAllowed( | |
['POST'], | |
content='{"error": "This API can only be accessed with a POST request."}', | |
content_type='application/json; charset=utf-8' | |
) | |
logger_extras = { | |
# We convert the page IDs to int because they come off the DB as longs, which get logged with an unwanted "L". | |
'category_id': int(cat.id), | |
'category_name': cat.name, | |
'parent_id': int(parent.id), | |
'parent_name': parent.name, | |
'position': position, | |
} | |
# Figure out which of parent's children we're supposed to move this category next to. | |
children = list(parent.get_children()) | |
try: | |
# Figure out which category, if any, exists at the specified location. | |
current_occupant = children[position] | |
except IndexError: | |
# No child exists at the specified position, so we insert this category at the end. This covers "category was | |
# moved from other parent to last of this parent's children" and "parent did not yet have any children". | |
cat.move(parent, 'last-child') | |
logger.info('sitemap.move.last-child', **logger_extras) | |
else: | |
# A category already exists at the specified location. | |
# We convert current_occupant.id from long to int, for aethetics in the logs. | |
logger_extras['sibling_id'] = int(current_occupant.id) | |
logger_extras['sibling_name'] = current_occupant.name | |
try: | |
old_position = children.index(cat) | |
except ValueError: | |
# This page was not already a child of its new parent, so we don't need the old_position logic. | |
# We must simply move the category that's currently at this position out of the way. | |
cat.move(current_occupant, 'left') | |
logger.info('sitemap.move.left-of', **logger_extras) | |
else: | |
# This category is being moved within its original sibling set. | |
if position < old_position: | |
# If it moved up in the table, the category in the specified position has to be moved out of the way to | |
# make room for it. So we insert to the left. | |
cat.move(current_occupant, 'left') | |
logger.info('sitemap.move.sibling.left-of', **logger_extras) | |
elif position > old_position: | |
# If it moved down in the table, its old position was vacated. To account for that, we | |
# need to insert to the right, instead. | |
cat.move(current_occupant, 'right') | |
logger.info('sitemap.move.sibling.right-of', **logger_extras) | |
else: | |
# The category is being inserted at its current location. So we do nothing. | |
logger.info('sitemap.move.noop', **logger_extras) | |
return JsonResponse({"status": "OK"}) | |
# We removed the Snippets tab so will need to create a ModelAdmin for each model we need users to be able to manage. | |
class PersonCategoryAdmin(WagtailSubmenuRegisterable, ModelAdmin): | |
model = PersonCategory | |
menu_icon = 'snippet' # change as required | |
menu_order = 750 # all our registered items are in the 700-800 range | |
# listing view options | |
list_per_page = 50 | |
list_display = ('get_as_listing_header', 'get_parent') | |
search_fields = ('name', 'description') | |
index_view_extra_js = ['caltech_sites/js/tabledrag.js'] | |
# inspect view options | |
inspect_view_enabled = True | |
inspect_view_fields = ('name', 'get_parent', 'description', 'id') | |
button_helper_class = PersonCategoryButtonHelper | |
def get_queryset(self, request): | |
qs = super().get_queryset(request) | |
# Only show authors for current site | |
return qs.filter(site=Site.find_for_request(request)) | |
def get_extra_attrs_for_row(self, obj, context): | |
return {"class": "draggable"} | |
def add_child_view(self, request, instance_pk): | |
"""Generate a class-based view to provide 'add child' functionality.""" | |
# instance_pk will become the default selected parent_pk | |
kwargs = {'model_admin': self, 'parent_pk': instance_pk} | |
view_class = AddChildCategoryViewClass | |
return view_class.as_view(**kwargs)(request) | |
def get_admin_urls_for_registration(self): | |
"""Add the new url for add child page to the registered URLs.""" | |
urls = super().get_admin_urls_for_registration() | |
add_child_url = re_path( | |
self.url_helper.get_action_url_pattern('add_child'), | |
self.add_child_view, | |
name=self.url_helper.get_action_url_name('add_child') | |
) | |
move_url = re_path( | |
self.url_helper.get_action_url_pattern('move_category'), | |
move_category, | |
name=self.url_helper.get_action_url_name('move_category') | |
) | |
return urls + (add_child_url, move_url, ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{% load caltech_sites_admin_tags %} | |
{% if is_root %} | |
<div style="display: table-cell;"> | |
<strong>{{ name }}</strong> | |
</div> | |
{% else %} | |
{% render_indentations depth %} | |
<div style="display: table-cell;"> | |
{{ name }} | |
</div> | |
{% endif %} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Drag and drop table rows with AJAX call to initiate page move. This has been adapted from the sitemap tabledrag.js | |
* | |
* If this js file in included in your ModelAdmin-derived index view using: | |
* index_view_extra_js = ['caltech_sites/js/tabledrag.js'] | |
* then any table with the classes "listing" and "full-width" will become sortable. | |
* See people_v2/people_categories PersonCategoryAdmin for an working example | |
* | |
*/ | |
(function($) { | |
var indentation_markup = '<div class="indentation"> </div>'; | |
var g_table_drag = null; | |
$(document).ready(function() { | |
var sortable_table = $('table.listing.full-width'); | |
var move_url_name = 'caltech_sites_personcategory_modeladmin_move_category'; | |
g_table_drag = new TableDrag(sortable_table[0], move_url_name); | |
}); | |
/** | |
* Constructor for the tableDrag object. Provides table and field manipulation. | |
* | |
* @param table | |
* DOM object for the table to be made draggable. | |
*/ | |
var TableDrag = function(table, move_url_name) { | |
var self = this; | |
// Required object variables. | |
this.table = table; | |
this.move_url_name = move_url_name; | |
this.dragObject = null; // Used to hold information about a current drag operation. | |
this.rowObject = null; // Provides operations for row manipulation. | |
this.oldRowElement = null; // Remember the previous element. | |
this.oldY = 0; // Used to determine up or down direction from last mouse move. | |
this.changed = false; // Whether anything in the entire table has changed. | |
this.rtl = $(this.table).css('direction') == 'rtl' ? -1 : 1; // Direction of the table. | |
// Configure the scroll settings. | |
this.scrollSettings = { amount: 4, interval: 50, trigger: 70 }; | |
this.scrollInterval = null; | |
this.scrollY = 0; | |
this.windowHeight = 0; | |
this.indentCount = 1; // Total width of indents, set in makeDraggable. | |
// Find the width of indentations to measure mouse movements against. | |
// Because the table doesn't need to start with any indentations, we | |
// manually append 2 indentations in the first draggable row, measure | |
// the offset, then remove. | |
var testRow = $('<tr/>').addClass('draggable').appendTo(table); | |
//noinspection JSUnresolvedVariable | |
var testCell = $('<td/>').appendTo(testRow).prepend(indentation_markup).prepend(indentation_markup); | |
this.indentAmount = $('.indentation', testCell).get(1).offsetLeft - $('.indentation', testCell).get(0).offsetLeft; | |
testRow.remove(); | |
// Make each applicable row draggable. | |
// Match immediate children of the parent element to allow nesting. | |
$('> tr.draggable, > tbody > tr.draggable', table).each(function() { self.makeDraggable(this); }); | |
// Initialize the specified columns (for example, weight or parent columns) | |
// to show or hide according to user preference. This aids accessibility | |
// so that, e.g., screen reader users can choose to enter weight values and | |
// manipulate form elements directly, rather than using drag-and-drop. | |
// self.initColumns(); | |
// Add mouse bindings to the document. The self variable is passed along | |
// as event handlers do not have direct access to the tableDrag object. | |
$(document).bind('mousemove pointermove', function(event) { return self.dragRow(event, self); }); | |
$(document).bind('mouseup pointerup', function(event) { return self.dropRow(event, self); }); | |
$(document).bind('touchmove', function(event) { return self.dragRow(event.originalEvent.touches[0], self); }); | |
$(document).bind('touchend', function(event) { return self.dropRow(event.originalEvent.touches[0], self); }); | |
}; | |
/** | |
* Take an item and add event handlers to make it become draggable. | |
*/ | |
TableDrag.prototype.makeDraggable = function(item) { | |
var self = this; | |
// Create the handle. | |
var handle = $('<a href="#" class="tabledrag-handle"><div class="drag-handle"> </div></a>').attr( | |
'title', 'Drag to re-order' | |
); | |
// Insert the handle after indentations (if any). | |
if ($('td:first .indentation:last', item).length) { | |
$('td:first .indentation:last', item).after(handle); | |
// Update the total width of indentation in this entire table. | |
self.indentCount = Math.max($('.indentation', item).length, self.indentCount); | |
} | |
// Add hover action for the handle. | |
handle.hover(function() { | |
self.dragObject == null ? $(this).addClass('tabledrag-handle-hover') : null; | |
}, function() { | |
self.dragObject == null ? $(this).removeClass('tabledrag-handle-hover') : null; | |
}); | |
// Add the mousedown action for the handle. | |
handle.bind('mousedown touchstart pointerdown', function(event) { | |
if (event.originalEvent.type == "touchstart") { | |
event = event.originalEvent.touches[0]; | |
} | |
// Create a new dragObject recording the event information. | |
self.dragObject = {}; | |
self.dragObject.initMouseOffset = self.getMouseOffset(item, event); | |
self.dragObject.initMouseCoords = self.mouseCoords(event); | |
self.dragObject.indentMousePos = self.dragObject.initMouseCoords; | |
// If there's a lingering row object from the keyboard, remove its focus. | |
if (self.rowObject) { | |
$('a.tabledrag-handle', self.rowObject.element).blur(); | |
} | |
// Create a new rowObject for manipulation of this row. | |
self.rowObject = new self.row(item, 'mouse', true); | |
// Save the position of the table. | |
self.table.topY = $(self.table).offset().top; | |
self.table.bottomY = self.table.topY + self.table.offsetHeight; | |
// Add classes to the handle and row. | |
$(this).addClass('tabledrag-handle-hover'); | |
$(item).addClass('drag'); | |
// Set the document to use the move cursor during drag. | |
$('body').addClass('drag'); | |
if (self.oldRowElement) { | |
$(self.oldRowElement).removeClass('drag-previous'); | |
} | |
// Hack for Konqueror, prevent the blur handler from firing. | |
// Konqueror always gives links focus, even after returning false on mousedown. | |
self.safeBlur = false; | |
// Call optional placeholder function. | |
self.onDrag(); | |
return false; | |
}); | |
// Prevent the anchor tag from jumping us to the top of the page. | |
handle.click(function() { | |
return false; | |
}); | |
// Similar to the hover event, add a class when the handle is focused. | |
handle.focus(function() { | |
$(this).addClass('tabledrag-handle-hover'); | |
self.safeBlur = true; | |
}); | |
// Remove the handle class on blur and fire the same function as a mouseup. | |
handle.blur(function(event) { | |
$(this).removeClass('tabledrag-handle-hover'); | |
if (self.rowObject && self.safeBlur) { | |
self.dropRow(event, self); | |
} | |
}); | |
// Add arrow-key support to the handle. | |
handle.keydown(function(event) { | |
// If a rowObject doesn't yet exist and this isn't the tab key. | |
if (event.keyCode != 9 && !self.rowObject) { | |
self.rowObject = new self.row(item, 'keyboard', true); | |
} | |
var keyChange = false; | |
switch (event.keyCode) { | |
case 37: // Left arrow. | |
case 63234: // Safari left arrow. | |
keyChange = true; | |
self.rowObject.indent(-1 * self.rtl); | |
break; | |
case 38: // Up arrow. | |
case 63232: // Safari up arrow. | |
var previousRow = $(self.rowObject.element).prev('tr').get(0); | |
while (previousRow && $(previousRow).is(':hidden')) { | |
previousRow = $(previousRow).prev('tr').get(0); | |
} | |
if (previousRow) { | |
self.safeBlur = false; // Do not allow the onBlur cleanup. | |
self.rowObject.direction = 'up'; | |
keyChange = true; | |
if ($(item).is('.tabledrag-root')) { | |
// Swap with the previous top-level row. | |
var groupHeight = 0; | |
while (previousRow && $('.indentation', previousRow).length) { | |
previousRow = $(previousRow).prev('tr').get(0); | |
groupHeight += $(previousRow).is(':hidden') ? 0 : previousRow.offsetHeight; | |
} | |
if (previousRow) { | |
self.rowObject.swap('before', previousRow); | |
// No need to check for indentation, 0 is the only valid one. | |
window.scrollBy(0, -groupHeight); | |
} | |
} | |
else if (self.table.tBodies[0].rows[0] != previousRow || $(previousRow).is('.draggable')) { | |
// Swap with the previous row (unless previous row is the first one | |
// and undraggable). | |
self.rowObject.swap('before', previousRow); | |
self.rowObject.interval = null; | |
self.rowObject.indent(0); | |
window.scrollBy(0, -parseInt(item.offsetHeight, 10)); | |
} | |
handle.get(0).focus(); // Regain focus after the DOM manipulation. | |
} | |
break; | |
case 39: // Right arrow. | |
case 63235: // Safari right arrow. | |
keyChange = true; | |
self.rowObject.indent(1 * self.rtl); | |
break; | |
case 40: // Down arrow. | |
case 63233: // Safari down arrow. | |
var nextRow = $(self.rowObject.group).filter(':last').next('tr').get(0); | |
while (nextRow && $(nextRow).is(':hidden')) { | |
nextRow = $(nextRow).next('tr').get(0); | |
} | |
if (nextRow) { | |
self.safeBlur = false; // Do not allow the onBlur cleanup. | |
self.rowObject.direction = 'down'; | |
keyChange = true; | |
if ($(item).is('.tabledrag-root')) { | |
// Swap with the next group (necessarily a top-level one). | |
var groupHeight = 0; | |
var nextGroup = new self.row(nextRow, 'keyboard', true); | |
if (nextGroup) { | |
$(nextGroup.group).each(function() { | |
groupHeight += $(this).is(':hidden') ? 0 : this.offsetHeight; | |
}); | |
var nextGroupRow = $(nextGroup.group).filter(':last').get(0); | |
self.rowObject.swap('after', nextGroupRow); | |
// No need to check for indentation, 0 is the only valid one. | |
window.scrollBy(0, parseInt(groupHeight, 10)); | |
} | |
} | |
else { | |
// Swap with the next row. | |
self.rowObject.swap('after', nextRow); | |
self.rowObject.interval = null; | |
self.rowObject.indent(0); | |
window.scrollBy(0, parseInt(item.offsetHeight, 10)); | |
} | |
handle.get(0).focus(); // Regain focus after the DOM manipulation. | |
} | |
break; | |
} | |
if (self.rowObject && self.rowObject.changed == true) { | |
$(item).addClass('drag'); | |
if (self.oldRowElement) { | |
$(self.oldRowElement).removeClass('drag-previous'); | |
} | |
self.oldRowElement = item; | |
self.restripeTable(); | |
self.onDrag(); | |
} | |
// Returning false if we have an arrow key to prevent scrolling. | |
if (keyChange) { | |
return false; | |
} | |
}); | |
// Compatibility addition, return false on keypress to prevent unwanted scrolling. | |
// IE and Safari will suppress scrolling on keydown, but all other browsers | |
// need to return false on keypress. http://www.quirksmode.org/js/keys.html | |
handle.keypress(function(event) { | |
switch (event.keyCode) { | |
case 37: // Left arrow. | |
case 38: // Up arrow. | |
case 39: // Right arrow. | |
case 40: // Down arrow. | |
return false; | |
} | |
}); | |
}; | |
/** | |
* Mousemove event handler, bound to document. | |
*/ | |
TableDrag.prototype.dragRow = function(event, self) { | |
if (self.dragObject) { | |
self.currentMouseCoords = self.mouseCoords(event); | |
var y = self.currentMouseCoords.y - self.dragObject.initMouseOffset.y; | |
var x = self.currentMouseCoords.x - self.dragObject.initMouseOffset.x; | |
// Check for row swapping and vertical scrolling. | |
if (y != self.oldY) { | |
self.rowObject.direction = y > self.oldY ? 'down' : 'up'; | |
self.oldY = y; // Update the old value. | |
// Check if the window should be scrolled (and how fast). | |
var scrollAmount = self.checkScroll(self.currentMouseCoords.y); | |
// Stop any current scrolling. | |
clearInterval(self.scrollInterval); | |
// Continue scrolling if the mouse has moved in the scroll direction. | |
if (scrollAmount > 0 && self.rowObject.direction == 'down' || | |
scrollAmount < 0 && self.rowObject.direction == 'up') { | |
self.setScroll(scrollAmount); | |
} | |
// If we have a valid target, perform the swap and restripe the table. | |
var currentRow = self.findDropTargetRow(x, y); | |
if (currentRow) { | |
if (self.rowObject.direction == 'down') { | |
self.rowObject.swap('after', currentRow, self); | |
} | |
else { | |
self.rowObject.swap('before', currentRow, self); | |
} | |
self.restripeTable(); | |
} | |
} | |
// Similar to row swapping, handle indentations. | |
var xDiff = self.currentMouseCoords.x - self.dragObject.indentMousePos.x; | |
// Set the number of indentations the mouse has been moved left or right. | |
var indentDiff = Math.round(xDiff / self.indentAmount); | |
// Indent the row with our estimated diff, which may be further | |
// restricted according to the rows around this row. | |
var indentChange = self.rowObject.indent(indentDiff); | |
// Update table and mouse indentations. | |
self.dragObject.indentMousePos.x += self.indentAmount * indentChange * self.rtl; | |
self.indentCount = Math.max(self.indentCount, self.rowObject.indents); | |
return false; | |
} | |
}; | |
/** | |
* Mouseup event handler, bound to document. | |
* Blur event handler, bound to drag handle for keyboard support. | |
*/ | |
TableDrag.prototype.dropRow = function(event, self) { | |
// Drop row functionality shared between mouseup and blur events. | |
if (self.rowObject != null) { | |
var droppedRow = self.rowObject.element; | |
// The row is already in the right place so we just release it. | |
if (self.rowObject.changed == true) { | |
self.updatePath(droppedRow); | |
} | |
self.rowObject.removeIndentClasses(); | |
if (self.oldRowElement) { | |
$(self.oldRowElement).removeClass('drag-previous'); | |
} | |
$(droppedRow).removeClass('drag').addClass('drag-previous'); | |
self.oldRowElement = droppedRow; | |
self.onDrop(); | |
self.rowObject = null; | |
} | |
// Functionality specific only to mouseup event. | |
if (self.dragObject != null) { | |
$('.tabledrag-handle', droppedRow).removeClass('tabledrag-handle-hover'); | |
self.dragObject = null; | |
$('body').removeClass('drag'); | |
clearInterval(self.scrollInterval); | |
} | |
}; | |
/** | |
* Get the mouse coordinates from the event (allowing for browser differences). | |
*/ | |
TableDrag.prototype.mouseCoords = function(event) { | |
if (event.pageX || event.pageY) { | |
return { x: event.pageX, y: event.pageY }; | |
} | |
return { | |
x: event.clientX + document.body.scrollLeft - document.body.clientLeft, | |
y: event.clientY + document.body.scrollTop - document.body.clientTop | |
}; | |
}; | |
/** | |
* Given a target element and a mouse event, get the mouse offset from that | |
* element. To do this we need the element's position and the mouse position. | |
*/ | |
TableDrag.prototype.getMouseOffset = function(target, event) { | |
var docPos = $(target).offset(); | |
var mousePos = this.mouseCoords(event); | |
return { x: mousePos.x - docPos.left, y: mousePos.y - docPos.top }; | |
}; | |
/** | |
* Find the row the mouse is currently over. This row is then taken and swapped | |
* with the one being dragged. | |
* | |
* @param x | |
* The x coordinate of the mouse on the page (not the screen). | |
* @param y | |
* The y coordinate of the mouse on the page (not the screen). | |
*/ | |
TableDrag.prototype.findDropTargetRow = function(x, y) { | |
var rows = $(this.table.tBodies[0].rows).not(':hidden'); | |
for (var n = 0; n < rows.length; n++) { | |
var row = rows[n]; | |
var rowY = $(row).offset().top; | |
// Because Safari does not report offsetHeight on table rows, but does on | |
// table cells, grab the firstChild of the row and use that instead. | |
// http://jacob.peargrove.com/blog/2006/technical/table-row-offsettop-bug-in-safari. | |
if (row.offsetHeight == 0) { | |
var rowHeight = parseInt(row.firstChild.offsetHeight, 10) / 2; | |
} | |
// Other browsers. | |
else { | |
var rowHeight = parseInt(row.offsetHeight, 10) / 2; | |
} | |
// Because we always insert before, we need to offset the height a bit. | |
if ((y > (rowY - rowHeight)) && (y < (rowY + rowHeight))) { | |
// Check that this row is not a child of the row being dragged. | |
for (var n in this.rowObject.group) { | |
if (this.rowObject.group[n] == row) { | |
return null; | |
} | |
} | |
// Check that swapping with this row is allowed. | |
if (!this.rowObject.isValidSwap(row)) { | |
return null; | |
} | |
// We may have found the row the mouse just passed over, but it doesn't | |
// take into account hidden rows. Skip backwards until we find a draggable | |
// row. | |
while ($(row).is(':hidden') && $(row).prev('tr').is(':hidden')) { | |
row = $(row).prev('tr').get(0); | |
} | |
return row; | |
} | |
} | |
return null; | |
}; | |
/** | |
* After the row is dropped, update the dropped Page's path based on where it was dropped in the table. | |
* | |
* @param changed_row | |
* DOM object for the row that was just dropped. | |
*/ | |
TableDrag.prototype.updatePath = function(changed_row) { | |
var changed_row_id = $(changed_row).data('object-pk'); | |
var row_obj = this.rowObject; | |
var parent_row = $(changed_row).prev('tr'); | |
// Search up the table for this row's new parent: the first one with less indentations than this row now has. | |
// Ignore the homepage, since it's the ultimate parent. | |
while (parent_row.length && $('.indentation', parent_row).length >= this.rowObject.indents && | |
!parent_row.hasClass('homepage')) { | |
parent_row = parent_row.prev('tr'); | |
} | |
var parent_page_id = $(parent_row).data('object-pk'); | |
// Figure out the page's position within its new sibling list by adding 1 to the start value of position for each row | |
// down it is from its parent. | |
var current_row = parent_row.next('tr'); | |
var position = 0; | |
while (current_row[0] != changed_row) { | |
// If this row has the same number of indents as our changed row, it's a sibling, so add to position. | |
if ($('.indentation', current_row).length == this.rowObject.indents) { | |
position += 1; | |
} | |
current_row = current_row.next('tr'); | |
} | |
//noinspection JSUnresolvedVariable | |
var move_url = URLs[this.move_url_name](changed_row_id); | |
// Perform the POST that executes the page move. | |
$.post(move_url, {'csrfmiddlewaretoken': getCookieValue('csrftoken'), | |
'destination_id': parent_page_id, | |
'position': position}) | |
.done(function(data) { | |
// 'data' is a dict keying two Page IDs to the replacement HTML contents for their More menus. | |
// It also may contain a 'message' key which should be dsplayed to the user. | |
for (var key in data) { | |
// We need to only replace the c-dropdown__menu element because replacing the entire More menu would break | |
// the event listeners. | |
if (key == 'message') { | |
var message_markup = '<div class="popup">' + | |
'This page\'s slug had to be changed because a sibling page already had the same slug. ' + | |
'If you wish to change this auto-generated slug, click <a href="' + data[key] + '">here</a> to edit the page.</div>'; | |
var popup = $(message_markup); | |
$('td.title', current_row).append(popup); | |
setTimeout(function() { | |
popup.fadeOut(function() { | |
popup.remove(); | |
}); | |
}, 7000); | |
} | |
else { | |
var more_menu = $(data[key]).find('ul.c-dropdown__menu'); | |
var old_menu = $('tr[data-object-pk="' + key + '"] ul.c-dropdown__menu'); | |
old_menu.html(more_menu.html()); | |
} | |
} | |
}) | |
.fail(function(jqXHR, textStatus, errorThrown) { | |
// rrollins 2017-05-18: This probably won't ever happen. I ran into this situation when I was developing | |
// the NewsIndexPage, and I wanted to put something in place to help developers of future similar functionality | |
// notice the problem that my original mechanism of page creation prevention was causing. | |
alert( | |
"We're sorry, but this drag action returned the following error:\n\"" + errorThrown + '"' + "\n\n" + | |
"The page will now reload to synchronize the correct state of the model." | |
); | |
location.reload() | |
}); | |
}; | |
TableDrag.prototype.checkScroll = function(cursorY) { | |
var de = document.documentElement; | |
var b = document.body; | |
var windowHeight = this.windowHeight = window.innerHeight || ( | |
de.clientHeight && de.clientWidth != 0 ? de.clientHeight : b.offsetHeight | |
); | |
var scrollY = this.scrollY = (document.all ? (!de.scrollTop ? b.scrollTop : de.scrollTop) : ( | |
window.pageYOffset ? window.pageYOffset : window.scrollY | |
)); | |
var trigger = this.scrollSettings.trigger; | |
var delta = 0; | |
// Return a scroll speed relative to the edge of the screen. | |
if (cursorY - scrollY > windowHeight - trigger) { | |
delta = trigger / (windowHeight + scrollY - cursorY); | |
delta = (delta > 0 && delta < trigger) ? delta : trigger; | |
return delta * this.scrollSettings.amount; | |
} | |
else if (cursorY - scrollY < trigger) { | |
delta = trigger / (cursorY - scrollY); | |
delta = (delta > 0 && delta < trigger) ? delta : trigger; | |
return -delta * this.scrollSettings.amount; | |
} | |
}; | |
TableDrag.prototype.setScroll = function(scrollAmount) { | |
var self = this; | |
this.scrollInterval = setInterval(function() { | |
// Update the scroll values stored in the object. | |
self.checkScroll(self.currentMouseCoords.y); | |
var aboveTable = self.scrollY > self.table.topY; | |
var belowTable = self.scrollY + self.windowHeight < self.table.bottomY; | |
if (scrollAmount > 0 && belowTable || scrollAmount < 0 && aboveTable) { | |
window.scrollBy(0, scrollAmount); | |
} | |
}, this.scrollSettings.interval); | |
}; | |
TableDrag.prototype.restripeTable = function() { | |
// :even and :odd are reversed because jQuery counts from 0 and | |
// we count from 1, so we're out of sync. | |
// Match immediate children of the parent element to allow nesting. | |
$('> tbody > tr.draggable:visible, > tr.draggable:visible', this.table) | |
.removeClass('odd even') | |
.filter(':odd').addClass('even').end() | |
.filter(':even').addClass('odd'); | |
}; | |
/** | |
* Stub function. Allows a custom handler when a row begins dragging. | |
*/ | |
TableDrag.prototype.onDrag = function() { | |
return null; | |
}; | |
/** | |
* Stub function. Allows a custom handler when a row is dropped. | |
*/ | |
TableDrag.prototype.onDrop = function() { | |
return null; | |
}; | |
/** | |
* Constructor to make a new object to manipulate a table row. | |
* | |
* @param tableRow | |
* The DOM element for the table row we will be manipulating. | |
* @param method | |
* The method in which this row is being moved. Either 'keyboard' or 'mouse'. | |
* @param addClasses | |
* Whether we want to add classes to this row to indicate child relationships. | |
*/ | |
TableDrag.prototype.row = function(tableRow, method, addClasses) { | |
this.element = tableRow; | |
this.method = method; | |
this.group = [tableRow]; | |
this.groupDepth = $('.indentation', tableRow).length; | |
this.changed = false; | |
this.table = $(tableRow).closest('table').get(0); | |
this.direction = ''; // Direction the row is being moved. | |
this.indents = $('.indentation', tableRow).length; | |
this.children = this.findChildren(addClasses); | |
this.group = $.merge(this.group, this.children); | |
// Find the depth of this entire group. | |
for (var n = 0; n < this.group.length; n++) { | |
this.groupDepth = Math.max($('.indentation', this.group[n]).length, this.groupDepth); | |
} | |
}; | |
/** | |
* Find all children of rowObject by indentation. | |
* | |
* @param addClasses | |
* Whether we want to add classes to this row to indicate child relationships. | |
*/ | |
TableDrag.prototype.row.prototype.findChildren = function(addClasses) { | |
var parentIndentation = this.indents; | |
var currentRow = $(this.element, this.table).next('tr.draggable'); | |
var rows = []; | |
var child = 0; | |
while (currentRow.length) { | |
var rowIndentation = $('.indentation', currentRow).length; | |
// A greater indentation indicates this is a child. | |
if (rowIndentation > parentIndentation) { | |
child++; | |
rows.push(currentRow[0]); | |
if (addClasses) { | |
$('.indentation', currentRow).each(function(indentNum) { | |
if (child == 1 && (indentNum == parentIndentation)) { | |
$(this).addClass('tree-child-first'); | |
} | |
if (indentNum == parentIndentation) { | |
$(this).addClass('tree-child'); | |
} | |
else if (indentNum > parentIndentation) { | |
$(this).addClass('tree-child-horizontal'); | |
} | |
}); | |
} | |
} | |
else { | |
break; | |
} | |
currentRow = currentRow.next('tr.draggable'); | |
} | |
if (addClasses && rows.length) { | |
$('.indentation:nth-child(' + (parentIndentation + 1) + ')', rows[rows.length - 1]).addClass('tree-child-last'); | |
} | |
return rows; | |
}; | |
/** | |
* Ensure that two rows are allowed to be swapped. | |
* | |
* @param row | |
* DOM object for the row being considered for swapping. | |
*/ | |
TableDrag.prototype.row.prototype.isValidSwap = function(row) { | |
var prevRow, nextRow; | |
if (this.direction == 'down') { | |
prevRow = row; | |
nextRow = $(row).next('tr').get(0); | |
} | |
else { | |
prevRow = $(row).prev('tr').get(0); | |
nextRow = row; | |
} | |
this.interval = this.validIndentInterval(prevRow, nextRow); | |
// We have an invalid swap if the valid indentations interval is empty. | |
if (this.interval.min > this.interval.max) { | |
return false; | |
} | |
// Do not let an un-draggable first row have anything put before it. | |
return !(this.table.tBodies[0].rows[0] == row && $(row).is(':not(.draggable)')); | |
}; | |
/** | |
* Perform the swap between two rows. | |
* | |
* @param position | |
* Whether the swap will occur 'before' or 'after' the given row. | |
* @param row | |
* DOM element what will be swapped with the row group. | |
*/ | |
TableDrag.prototype.row.prototype.swap = function(position, row) { | |
// Drupal.detachBehaviors(this.group, Drupal.settings, 'move'); | |
$(row)[position](this.group); | |
// Drupal.attachBehaviors(this.group, Drupal.settings); | |
this.changed = true; | |
}; | |
/** | |
* Determine the valid indentations interval for the row at a given position | |
* in the table. | |
* | |
* @param prevRow | |
* DOM object for the row before the tested position | |
* (or null for first position in the table). | |
* @param nextRow | |
* DOM object for the row after the tested position | |
* (or null for last position in the table). | |
*/ | |
TableDrag.prototype.row.prototype.validIndentInterval = function(prevRow, nextRow) { | |
var minIndent, maxIndent; | |
// Minimum indentation: | |
// Do not orphan the next row. | |
minIndent = nextRow ? $('.indentation', nextRow).length : 0; | |
// Maximum indentation: | |
if (!prevRow || $(prevRow).is(':not(.draggable)') || $(this.element).is('.tabledrag-root')) { | |
// Do not indent: | |
// - the first row in the table, | |
// - rows dragged below a non-draggable row, | |
// - 'root' rows. | |
maxIndent = 0; | |
} | |
else { | |
// Do not go deeper than as a child of the previous row. | |
maxIndent = $('.indentation', prevRow).length + ($(prevRow).is('.tabledrag-leaf') ? 0 : 1); | |
} | |
return { 'min': minIndent, 'max': maxIndent }; | |
}; | |
/** | |
* Indent a row within the legal bounds of the table. | |
* | |
* @param indentDiff | |
* The number of additional indentations proposed for the row (can be | |
* positive or negative). This number will be adjusted to nearest valid | |
* indentation level for the row. | |
*/ | |
TableDrag.prototype.row.prototype.indent = function(indentDiff) { | |
// Determine the valid indentations interval if not available yet. | |
if (!this.interval) { | |
var prevRow = $(this.element).prev('tr').get(0); | |
var nextRow = $(this.group).filter(':last').next('tr').get(0); | |
this.interval = this.validIndentInterval(prevRow, nextRow); | |
} | |
// Adjust to the nearest valid indentation. | |
var indent = this.indents + indentDiff; | |
indent = Math.max(indent, this.interval.min); | |
indent = Math.min(indent, this.interval.max); | |
indentDiff = indent - this.indents; | |
for (var n = 1; n <= Math.abs(indentDiff); n++) { | |
// Add or remove indentations. | |
if (indentDiff < 0) { | |
$('.indentation:first', this.group).remove(); | |
this.indents--; | |
} | |
else { | |
$('td:first', this.group).prepend(indentation_markup); | |
this.indents++; | |
} | |
} | |
if (indentDiff) { | |
// Update indentation for this row. | |
this.changed = true; | |
this.groupDepth += indentDiff; | |
} | |
return indentDiff; | |
}; | |
/** | |
* Remove indentation helper classes from the current row group. | |
*/ | |
TableDrag.prototype.row.prototype.removeIndentClasses = function() { | |
for (var n in this.children) { | |
$('.indentation', this.children[n]) | |
.removeClass('tree-child') | |
.removeClass('tree-child-first') | |
.removeClass('tree-child-last') | |
.removeClass('tree-child-horizontal'); | |
} | |
}; | |
// Utility function used by TableDrag.updatePath() to retrieve the CSFR Token. | |
function getCookieValue(name) { | |
var matches = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)'); | |
return matches ? matches.pop() : ''; | |
} | |
})(jQuery); | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment