Add preliminairy working JavaScript to render polymorphic inlines

fix_request_path_info
Diederik van der Boor 2016-08-09 01:20:40 +02:00
parent 1f0ddd8436
commit 7330a4f099
7 changed files with 552 additions and 1 deletions

View File

@ -2,4 +2,5 @@ include README.rst
include LICENSE
include DOCS.rst
include CHANGES.rst
recursive-include polymorphic/static *.js *.css
recursive-include polymorphic/templates *

View File

@ -3,8 +3,13 @@ Rendering utils for admin forms;
This makes sure that admin fieldsets/layout settings are exported to the template.
"""
import json
import django
from django.contrib.admin.helpers import InlineAdminFormSet, InlineAdminForm, AdminField
from django.utils.encoding import force_text
from django.utils.text import capfirst
from django.utils.translation import ugettext
from polymorphic.formsets import BasePolymorphicModelFormSet
@ -14,6 +19,9 @@ class PolymorphicInlineAdminForm(InlineAdminForm):
Expose the admin configuration for a form
"""
def polymorphic_ctype_field(self):
return AdminField(self.form, 'polymorphic_ctype', False)
class PolymorphicInlineAdminFormSet(InlineAdminFormSet):
"""
@ -71,6 +79,30 @@ class PolymorphicInlineAdminFormSet(InlineAdminFormSet):
fields.update(child_inline.get_prepopulated_fields(self.request, self.obj))
return fields
# The polymorphic template follows the same method like all other inlines do in Django 1.10.
# This method is added for compatibility with older Django versions.
def inline_formset_data(self):
"""
A JavaScript data structure for the JavaScript code
"""
verbose_name = self.opts.verbose_name
return json.dumps({
'name': '#%s' % self.formset.prefix,
'options': {
'prefix': self.formset.prefix,
'addText': ugettext('Add another %(verbose_name)s') % {
'verbose_name': capfirst(verbose_name),
},
'childTypes': [
{
'type': model._meta.model_name,
'name': force_text(model._meta.verbose_name)
} for model in self.formset.child_forms.keys()
],
'deleteText': ugettext('Remove'),
}
})
class PolymorphicInlineSupportMixin(object):
"""

View File

@ -22,8 +22,22 @@ class PolymorphicInlineModelAdmin(InlineModelAdmin):
* Permissions are only checked on the base model.
* The child inlines can't override the base model fields, only this parent inline can do that.
"""
formset = BasePolymorphicInlineFormSet
#: The extra media to add for the polymorphic inlines effect.
#: This can be redefined for subclasses.
polymorphic_media = Media(
js=(
'polymorphic/js/jquery.django-inlines.js',
),
css={
'all': (
'polymorphic/css/polymorphic_inlines.css',
)
}
)
#: The extra forms to show
#: By default there are no 'extra' forms as the desired type is unknown.
#: Instead, add each new item using JavaScript that first offers a type-selection.

View File

@ -26,7 +26,7 @@ class PolymorphicFormSetChild(object):
# This is mostly needed for the generic inline formsets
self._form_base = form
self.fields = fields
self.exclude = exclude
self.exclude = exclude or ()
self.formfield_callback = formfield_callback
self.widgets = widgets
self.localized_fields = localized_fields

View File

@ -0,0 +1,27 @@
.add-row-choice {
position: relative;
}
.add-row-choice a:focus {
text-decoration: none;
}
.add-row .inline-type-choice {
position: absolute;
top: 2.2em;
left: 0.5em;
border: 1px solid #ccc;
border-radius: 4px;
padding: 2px;
background-color: #fff;
}
.add-row .inline-type-choice ul {
padding: 2px;
margin: 0;
}
.add-row .inline-type-choice li {
list-style: none inside none;
padding: 4px 8px;
}

View File

@ -0,0 +1,440 @@
/**
* jQuery plugin for Django inlines
*
* (c) 2011-2016 Diederik van der Boor, Apache 2 Licensed.
*/
(function($){
function DjangoInline(group, options) {
options = $.extend({}, $.fn.djangoInline.defaults, options);
this.group = group;
this.$group = $(group);
this.options = options;
options.prefix = options.prefix || this.$group.attr('id').replace(/-group$/, '');
if( options.formTemplate ) {
this.$form_template = $(options.formTemplate);
} else {
this.$form_template = this.$group.find(this.options.emptyFormSelector); // the extra item to construct new instances.
}
// Create the add button if requested (null/undefined means auto select)
if(options.showAddButton !== false) {
var dominfo = this._getManagementForm();
if (dominfo.max_forms == null || dominfo.max_forms.value === '' || (dominfo.max_forms.value - dominfo.total_forms.value) > 0) {
this.createAddButton();
}
}
}
DjangoInline.prototype = {
/**
* Create the add button
*/
createAddButton: function() {
var $addButton;
var myself = this;
if (this.options.childTypes) {
// Polymorphic inlines!
// The add button opens a menu.
var menu = '<div class="inline-type-choice" style="display: none;"><ul>';
for (var i = 0; i < this.options.childTypes.length; i++) {
var obj = this.options.childTypes[i];
menu += '<li><a href="#" data-type="' + obj.type + '">' + obj.name + '</a></li>';
}
menu += '</ul></div>';
$addButton = $('<div class="' + this.options.addCssClass + ' add-row-choice"><a href="#">' + this.options.addText + "</a>" + menu + "</div>");
this.$group.append($addButton);
$addButton.children('a').click($.proxy(this._onMenuToggle, this));
$addButton.find('li a').click(function(event){ myself._onMenuItemClick(event); });
}
else {
// Normal inlines
$addButton = $('<div class="' + this.options.addCssClass + '"><a href="#">' + this.options.addText + "</a></div>");
this.$group.append($addButton);
$addButton.find('a').click(function(event) { event.preventDefault(); myself.addForm() });
}
},
_onMenuToggle: function(event) {
event.preventDefault();
event.stopPropagation();
var $menu = $(event.target).next('.inline-type-choice');
if(! $menu.is(':visible')) {
function hideMenu() {
$menu.slideUp();
$(document).unbind('click', hideMenu);
}
$(document).click(hideMenu);
}
$menu.slideToggle();
},
_onMenuItemClick: function(event) {
event.preventDefault();
var type = $(event.target).attr('data-type');
var empty_form_selector = this.options.emptyFormSelector + "[data-inline-type=" + type + "]";
this.addForm(empty_form_selector);
},
/**
* The main action, add a new row.
* Allow to select a different form template (for polymorphic inlines)
*/
addForm: function(emptyFormSelector) {
var $form_template;
if(emptyFormSelector) {
$form_template = this.$group.find(emptyFormSelector);
if($form_template.length === 0) {
throw new Error("Form template '" + emptyFormSelector + "' not found")
}
}
else {
if(! this.$form_template || this.$form_template.length === 0) {
throw new Error("No empty form available. Define the 'form_template' setting or add an '.empty-form' element in the '" + this.options.prefix + "' formset group!");
}
$form_template = this.$form_template;
}
// The Django admin/media/js/inlines.js API is not public, or easy to use.
// Recoded the inline model dynamics.
var management_form = this._getManagementForm();
if(! management_form.total_forms) {
throw new Error("Missing '#" + this._getGroupFieldIdPrefix() + "-TOTAL_FORMS' field. Make sure the management form included!");
}
// When a inline is presented in a complex table,
// the newFormTarget can be very useful to direct the output.
var container;
if(this.options.newFormTarget == null) {
container = $form_template.parent();
}
else if($.isFunction(this.options.newFormTarget)) {
container = this.options.newFormTarget.apply(this.group);
}
else {
container = this.$group.find(this.options.newFormTarget);
}
if(container === null || container.length === 0) {
throw new Error("No container found via custom 'newFormTarget' function!");
}
// Clone the item.
var new_index = management_form.total_forms.value;
var item_id = this._getFormId(new_index);
var newhtml = _getOuterHtml($form_template).replace(/__prefix__/g, new_index);
var newitem = $(newhtml).removeClass("empty-form").attr("id", item_id);
// Add it
container.append(newitem);
var formset_item = $("#" + item_id);
if( formset_item.length === 0 ) {
throw new Error("New FormSet item not found: #" + item_id);
}
formset_item.data('djangoInlineIndex', new_index);
if(this.options.onAdd) {
this.options.onAdd.call(this.group, formset_item, new_index, this.options);
}
// Update administration
management_form.total_forms.value++;
return formset_item;
},
getFormAt: function(index) {
return $('#' + this._getFormId(index));
},
_getFormId: function(index) {
// The form container is expected by the numbered as #prefix-NR
return this.options.itemIdTemplate.replace('{prefix}', this.options.prefix).replace('{index}', index);
},
_getGroupFieldIdPrefix: function() {
// typically: #id_modelname
return this.options.autoId.replace('{prefix}', this.options.prefix);
},
/**
* Get the management form data.
*/
_getManagementForm: function() {
var group_id_prefix = this._getGroupFieldIdPrefix();
return {
// management form item
total_forms: $("#" + group_id_prefix + "-TOTAL_FORMS")[0],
max_forms: $("#" + group_id_prefix + "-MAX_NUM_FORMS")[0],
group_id_prefix: group_id_prefix
}
},
_getItemData: function(child_node) {
var formset_item = $(child_node).closest(this.options.itemsSelector);
if( formset_item.length === 0 ) {
return null;
}
// Split the ID, using the id_template pattern.
// note that ^...$ is important, as a '-' char can occur multiple times with generic inlines (inlinetype-id / app-model-ctfield-ctfkfield-id)
var id = formset_item.attr("id");
var cap = (new RegExp('^' + this.options.itemIdTemplate.replace('{prefix}', '(.+?)').replace('{index}', '(\\d+)') + '$')).exec(id);
return {
formset_item: formset_item,
prefix: cap[1],
index: parseInt(cap[2], 0) // or parseInt(formset_item.data('djangoInlineIndex'))
};
},
/**
* Get the meta-data of a single form.
*/
_getItemForm: function(child_node) {
var dominfo = this._getItemData(child_node);
if( dominfo === null ) {
return null;
}
var field_id_prefix = this._getGroupFieldIdPrefix() + "-" + dominfo.index;
return $.extend({}, dominfo, {
// Export settings data
field_id_prefix: field_id_prefix,
field_name_prefix: dominfo.prefix + '-' + dominfo.index,
// Item fields
pk_field: $('#' + field_id_prefix + '-' + this.options.pkFieldName),
delete_checkbox: $("#" + field_id_prefix + "-DELETE")
});
},
/**
* Remove a row
*/
removeForm: function(child_node)
{
// Get dom info
var management_form = this._getManagementForm();
var itemform = this._getItemForm(child_node);
if( itemform === null ) {
throw new Error("No form found for the selector '" + child_node.selector + "'!");
}
var total_count = parseInt(management_form.total_forms.value, 0);
var has_pk_field = itemform.pk_field.length != 0;
if(this.options.onBeforeRemove) {
this.options.onBeforeRemove.call(this.group, itemform.formset_item, this.options);
}
// In case there is a delete checkbox, save it.
if( itemform.delete_checkbox.length )
{
if(has_pk_field)
itemform.pk_field.insertAfter(management_form.total_forms);
itemform.delete_checkbox.attr('checked', true).insertAfter(management_form.total_forms).hide();
}
else if( has_pk_field && itemform.pk_field[0].value )
{
// Construct a delete checkbox on the fly.
itemform.pk_field.insertAfter(management_form.total_forms);
$('<input type="hidden" id="' + itemform.field_id_prefix + '-DELETE" name="' + itemform.field_name_prefix + '-DELETE" value="on">').insertAfter(itemform.total_forms);
}
else
{
// Newly added item, renumber in reverse order
for( var i = itemform.index + 1; i < total_count; i++ )
{
this._renumberItem(this.getFormAt(i), i - 1);
}
management_form.total_forms.value--;
}
// And remove item
itemform.formset_item.remove();
if(this.options.onRemove) {
this.options.onRemove.call(this.group, itemform.formset_item, this.options);
}
return itemform.formset_item;
},
// Based on django/contrib/admin/media/js/inlines.js
_renumberItem: function($formset_item, new_index)
{
var id_regex = new RegExp("(" + this._getFormId('(\\d+|__prefix__)') + ")");
var replacement = this._getFormId(new_index);
$formset_item.data('djangoInlineIndex', new_index);
// Loop through the nodes.
// Getting them all at once turns out to be more efficient, then looping per level.
var nodes = $formset_item.add( $formset_item.find("*") );
for( var i = 0; i < nodes.length; i++ )
{
var node = nodes[i];
var $node = $(node);
var for_attr = $node.attr('for');
if( for_attr && for_attr.match(id_regex) ) {
$node.attr("for", for_attr.replace(id_regex, replacement));
}
if( node.id && node.id.match(id_regex) ) {
node.id = node.id.replace(id_regex, replacement);
}
if( node.name && node.name.match(id_regex) ) {
node.name = node.name.replace(id_regex, replacement);
}
}
},
// Extra query methods for external callers:
getFormIndex: function(child_node) {
var dominfo = this._getItemData(child_node);
return dominfo ? dominfo.index : null;
},
getForms: function() {
// typically: .inline-related:not(.empty-form)
return this.$group.children(this.options.itemsSelector + ":not(" + this.options.emptyFormSelector + ")");
},
getEmptyForm: function() {
// typically: #modelname-group > .empty-form
return this.$form_template;
},
getFieldIdPrefix: function(item_index) {
if(! $.isNumeric(item_index)) {
var dominfo = this._getItemData(item_index);
if(dominfo === null) {
throw new Error("Unexpected element in getFieldIdPrefix, needs to be item_index, or DOM child node.");
}
item_index = dominfo.index;
}
// typically: #id_modelname-NN
return this._getGroupFieldIdPrefix() + "-" + item_index;
},
getFieldsAt: function(index) {
var $form = this.getFormAt(index);
return this.getFields($form);
},
getFields: function(child_node) {
// Return all fields in a simple lookup object, with the prefix stripped.
var dominfo = this._getItemData(child_node);
if(dominfo === null) {
return null;
}
var fields = {};
var $inputs = dominfo.formset_item.find(':input');
var name_prefix = this.prefix + "-" + dominfo.index;
for(var i = 0; i < $inputs.length; i++) {
var name = $inputs[i].name;
if(name.substring(0, name_prefix.length) == name_prefix) {
var suffix = name.substring(name_prefix.length + 1); // prefix-<name>
fields[suffix] = $inputs[i];
}
}
return fields;
},
removeFormAt: function(index) {
return this.removeForm(this.getFormAt(index));
}
};
function _getOuterHtml($node)
{
if( $node.length )
{
if( $node[0].outerHTML ) {
return $node[0].outerHTML;
} else {
return $("<div>").append($node.clone()).html();
}
}
return null;
}
// jQuery plugin definition
// Separated from the main code, as demonstrated by Twitter bootstrap.
$.fn.djangoInline = function(option) {
var args = Array.prototype.splice.call(arguments, 1);
var call_method = (typeof option == 'string');
var plugin_result = (call_method ? undefined : this);
this.filter('.inline-group').each(function() {
var $this = $(this);
var data = $this.data('djangoInline');
if (! data) {
var options = typeof option == 'object' ? option : {};
$this.data('djangoInline', (data = new DjangoInline(this, options)));
}
if (typeof option == 'string') {
plugin_result = data[option].apply(data, args);
}
});
return plugin_result;
};
$.fn.djangoInline.defaults = {
pkFieldName: 'id', // can be `tablename_ptr` for inherited models.
autoId: 'id_{prefix}', // the auto id format used in Django.
prefix: null, // typically the model name in lower case.
newFormTarget: null, // Define where the row should be added; a CSS selector or function.
itemIdTemplate: '{prefix}-{index}', // Format of the ID attribute.
itemsSelector: '.inline-related', // CSS class that each item has
emptyFormSelector: '.empty-form', // CSS class that
formTemplate: null, // Complete HTML of the new form
childTypes: null, // Extra for django-polymorphic, allow a choice between empty-forms.
showAddButton: true,
addText: "add another", // Text for the add link
deleteText: "remove", // Text for the delete link
addCssClass: "add-row" // CSS class applied to the add link
};
// Also expose inner object
$.fn.djangoInline.Constructor = DjangoInline;
// Auto enable inlines
$.fn.ready(function(){
$('.js-jquery-django-inlines').each(function(){
var $this = $(this);
var data = $this.data();
var inlineOptions = data.inlineFormset;
$this.djangoInline(inlineOptions.options)
});
})
})(window.django ? window.django.jQuery : jQuery);

View File

@ -0,0 +1,37 @@
{% load i18n admin_urls static %}
<div class="js-jquery-django-inlines inline-group"
id="{{ inline_admin_formset.formset.prefix }}-group"
data-inline-type="stacked"
data-inline-formset="{{ inline_admin_formset.inline_formset_data }}">
<fieldset class="module {{ inline_admin_formset.classes }}">
<h2>{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}</h2>
{{ inline_admin_formset.formset.management_form }}
{{ inline_admin_formset.formset.non_form_errors }}
{% for inline_admin_form in inline_admin_formset %}
<div class="inline-related inline-{{ inline_admin_form.model_admin.opts.model_name }}{% if inline_admin_form.original or inline_admin_form.show_url %} has_original{% endif %}{% if not inline_admin_form.original.pk %} empty-form {% endif %}{% if forloop.last %} last-related{% endif %}"
id="{{ inline_admin_formset.formset.prefix }}-{% if inline_admin_form.original.pk %}{{ forloop.counter0 }}{% else %}empty{% endif %}"
data-inline-type="{{ inline_admin_form.model_admin.opts.model_name }}">
<h3><b>{{ inline_admin_form.model_admin.opts.verbose_name|capfirst }}:</b>&nbsp;<span class="inline_label">{% if inline_admin_form.original %}{{ inline_admin_form.original }}{% if inline_admin_form.model_admin.show_change_link and inline_admin_form.model_admin.has_registered_model %} <a href="{% url inline_admin_form.model_admin.opts|admin_urlname:'change' inline_admin_form.original.pk|admin_urlquote %}" class="inlinechangelink">{% trans "Change" %}</a>{% endif %}
{% else %}#{{ forloop.counter }}{% endif %}</span>
{% if inline_admin_form.show_url %}<a href="{{ inline_admin_form.absolute_url }}">{% trans "View on site" %}</a>{% endif %}
{% if inline_admin_form.formset.can_delete and inline_admin_form.original %}<span class="delete">{{ inline_admin_form.deletion_field.field }} {{ inline_admin_form.deletion_field.label_tag }}</span>{% endif %}
</h3>
{% if inline_admin_form.form.non_field_errors %}{{ inline_admin_form.form.non_field_errors }}{% endif %}
{% for fieldset in inline_admin_form %}
{% include "admin/includes/fieldset.html" %}
{% endfor %}
{% if inline_admin_form.needs_explicit_pk_field %}{{ inline_admin_form.pk_field.field }}{% endif %}
{{ inline_admin_form.fk_field.field }}
{{ inline_admin_form.polymorphic_ctype_field.field }}
</div>
{% endfor %}
</fieldset>
</div>