Login

Dynamic tabular inlines with optional drag-n-drop sorting

Author:
Aneon
Posted:
May 5, 2009
Language:
JavaScript
Version:
Not specified
Score:
9 (after 9 ratings)

This jQuery javascript enables dynamic add/delete of rows in tabular inlines. It adds a "+" icon at the bottom of the inline to allow addition of new rows, and replaces the default delete checkbox with a "x" icon for deletion, giving you the possibility to add/delete rows instantly without reloading the page.

In addition, it gives you drag-n-drop ordering functionality with a named position model field using jQuery UI Sortable.

Usage (see below for example):

Just include the javascript on your admin page, together with jQuery, and it'll automatically affect all tabular inlines. Optionally, also include jQuery UI Sortable and an Integer field in your inline model named "position" (or whatever you set "position_field" to), which will automatically hide the position field and enable drag-n-drop sorting.

Developed for:

  • jQuery 1.3.2
  • jQuery UI 1.7.1
  • Django trunk (tested in Django v1.0.2)
  • (Might work with other versions with or without adjustments, but not tested)

Settings (in top of javascript):

  • "position_field" is the name of an integer model field that is used for ordering the inline model. If left empty or not found, the drag-n-drop functionality is dropped. Defaults to "position".
  • "add_link_html" for custom look of "add"-buttons. Defaults to Django's built-in "+" image icon.
  • "delete_link_html" for custom look of "delete"-buttons. Defaults to Django's built-in "x" image icon.

Use example:

admin.py:

class NameInline(admin.TabularInline):
    model = Name
    extra = 1

class PersonAdmin(admin.ModelAdmin):    
    inlines = [NameInline]

    class Media:
        js = ['js/jquery-1.3.2.min.js', 'js/ui/ui.core.js',
            'js/ui/ui.sortable.js', 'js/dynamic_inlines_with_sort.js',]
        css = { 'all' : ['css/dynamic_inlines_with_sort.css'], }

admin.site.register(Person, PersonAdmin)

models.py:

class Person(models.Model):
    year_born = models.PositiveIntegerField(_('year born'), null=True, blank=True)

class Name(models.Model):
    profile = models.ForeignKey(Profile, verbose_name=_('profile'))
    position = models.PositiveIntegerField(_('position'), default=0)
    name = models.CharField(_('name'), max_length=100)

    class Meta:
        ordering = ('position',)

dynamic_inlines_with_sort.css:

/* To make row height of saved items same as others */
.inline-group .tabular tr.has_original td { padding-top:0.5em; }
.inline-group .tabular tr.has_original td.original p { display:none; }

Please post bugs in comments.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
/* dynamic_inlines_with_sort.js */
/* Created in May 2009 by Hannes Rydén */
/* Use, distribute and modify freely */

// "Add"-link html code. Defaults to Django's "+" image icon, but could use text instead.
add_link_html = '<img src="/media/admin/img/admin/icon_addlink.gif" ' +
    'width="10" height="10" alt="Add new row" style="margin:0.5em 1em;" />';
// "Delete"-link html code. Defaults to Django's "x" image icon, but could use text instead.
delete_link_html = '<img src="/media/admin/img/admin/icon_deletelink.gif" ' +
    'width="10" height="10" alt="Delete row" style="margin-top:0.5em" />';
position_field = 'position'; // Name of inline model field (integer) used for ordering. Defaults to "position".

jQuery(function($) {    
    // This script is applied to all TABULAR inlines
    $('div.inline-group div.tabular').each(function() {
        table = $(this).find('table');
                        
        // Hide initial extra row and prepare it to be used as a template for new rows
        add_template = table.find('tr:last');
        add_template.addClass('add_template').hide();
        table.prepend(add_template);
        
        // Hide initial deleted rows
        table.find('td.delete input:checkbox:checked').parent('td').parent('tr').addClass('deleted_row').hide();
        
        // "Add"-button in bottom of inline for adding new rows
        $(this).find('fieldset').after('<a class="add" href="#">' + add_link_html + '</a>');

        $(this).find('a.add').click(function(){
            old_item = $(this).parent().find('table tr.add_template')
            new_item = old_item.clone(true);

            create_delete_button(new_item.find('td.delete'));
            new_item.removeClass('add_template').show();
                        
            $(this).parent().find('table').append(new_item);

            update_positions($(this).parent().find('table'), true);
            
            // Place for special code to re-enable javascript widgets after clone (e.g. an ajax-autocomplete field)
            // Fictive example: new_item.find('.autocomplete').each(function() { $(this).triggerHandler('autocomplete'); });
        }).removeAttr('href').css('cursor', 'pointer');
        
        // "Delete"-buttons for each row that replaces the default checkbox 
        table.find('tr:not(.add_template) td.delete').each(function() {
            create_delete_button($(this));
        });

        // Drag and drop functionality - only used if a position field exists
        if (position_field != '' && table.find('td').is('.' + position_field))
        {
            // Hide "position"-field (both td:s and th:s)
            $(this).find('td.' + position_field).hide();
            td_pos_field_index = table.find('tbody tr td').index($(this).find('td.' + position_field));
            $(this).find('th:eq(' + (td_pos_field_index-1) + ')').hide();
            
            // Hide "original"-field and set any colspan to 1 (why show in the first case?)
            $(this).find('td.original').hide();
            $(this).find('th[colspan]').removeAttr('colspan');

            // Make table sortable using jQuery UI Sortable
            table.sortable({
                items: 'tr:has(td)',
                tolerance: 'pointer',
                axis: 'y',
                cancel: 'input,button,select,a',
                helper: 'clone',
                update: function() {
                    update_positions($(this));
                }
            });
            
            
            // Re-order <tr>:s based on the "position"-field values.
            // This is a very simple ordering which only works with correct position number sequences,
            // which the rest of this script (hopefully) guarantees.
            rows = [];
            table.find('tbody tr').each(function() {
                position = $(this).find('td.' + position_field + ' input').val();
                rows[position] = $(this);
                
                // Add move cursor to table row.
                // Also remove row coloring, as it confuses when using drag-and-drop for ordering
                table.find('tr:has(td)').css('cursor', 'move').removeClass('row1').removeClass('row2');
            });
            
            for (var i in rows) { table.append(rows[i]); } // Move <tr> to its correct position
            update_positions($(this), true);
        }
        else
            position_field = '';
    });
});

// Function for creating fancy delete buttons
function create_delete_button(td)
{
     // Replace checkbox with image
    td.find('input:checkbox').hide();
    td.append('<a class="delete" href="#">' + delete_link_html + '</a>');
    
    td.find('a.delete').click(function(){
        current_row = $(this).parent('td').parent('tr');
        table = current_row.parent().parent();
        if (current_row.is('.has_original')) // This row has already been saved once, so we must keep checkbox
        {
            $(this).prev('input').attr('checked', true);
            current_row.addClass('deleted_row').hide();
        }
        else // This row has never been saved so we can just remove the element completely
        {
            current_row.remove();
        }
        
        update_positions(table, true);
    }).removeAttr('href').css('cursor', 'pointer');
}

// Updates "position"-field values based on row order in table
function update_positions(table, update_ids)
{
    even = true
    num_rows = 0
    position = 0;

    // Set correct position: Filter through all trs, excluding first th tr and last hidden template tr
    table.find('tbody tr:not(.add_template):not(.deleted_row)').each(function() {
        if (position_field != '')
        {
            // Update position field
            $(this).find('td.' + position_field + ' input').val(position + 1);
            position++;
        }
        else
        {
            // Update row coloring
            $(this).removeClass('row1 row2');
            if (even)
            {
                $(this).addClass('row1');
                even = false;
            }
            else
            {
                $(this).addClass('row2');
                even = true;
            }
        }
    });
    
    table.find('tbody tr.has_original').each(function() {
        num_rows++;
    });
    
    table.find('tbody tr:not(.has_original):not(.add_template)').each(function() {
        if (update_ids) update_id_fields($(this), num_rows);
        num_rows++;
    });    
    
    table.find('tbody tr.add_template').each(function() {
        if (update_ids) update_id_fields($(this), num_rows)
        num_rows++;
    });

    table.parent().parent('div.tabular').find("input[id$='TOTAL_FORMS']").val(num_rows);
}

// Updates actual id and name attributes of inputs, selects and so on.
// Required for Django validation to keep row order.
function update_id_fields(row, new_position)
{
    // Fix IDs, names etc.
    
    // <select ...>
    row.find('select').each(function() {
        // id=...
        old_id = $(this).attr('id').toString();
        new_id = old_id.replace(/([^ ]+\-)[0-9]+(\-[^ ]+)/i, "$1" + new_position + "$2");
        $(this).attr('id', new_id)
        
        // name=...
        old_id = $(this).attr('name').toString();
        new_id = old_id.replace(/([^ ]+\-)[0-9]+(\-[^ ]+)/i, "$1" + new_position + "$2");
        $(this).attr('name', new_id)
    });
    
    // <input ...>
    row.find('input').each(function() {
        // id=...
        old_id = $(this).attr('id').toString();
        new_id = old_id.replace(/([^ ]+\-)[0-9]+(\-[^ ]+)/i, "$1" + new_position + "$2");
        $(this).attr('id', new_id)
        
        // name=...
        old_id = $(this).attr('name').toString();
        new_id = old_id.replace(/([^ ]+\-)[0-9]+(\-[^ ]+)/i, "$1" + new_position + "$2");
        $(this).attr('name', new_id)
    });
    
    // <a ...>
    row.find('a').each(function() {
        // id=...
        old_id = $(this).attr('id').toString();
        new_id = old_id.replace(/([^ ]+\-)[0-9]+(\-[^ ]+)/i, "$1" + new_position + "$2");
        $(this).attr('id', new_id)
    });
    
    // Are there other element types...? Add here.
}

More like this

  1. Django Collapsed Stacked Inlines by applecat 1 year, 1 month ago
  2. Django Collapsed Stacked Inlines by mkarajohn 3 years, 2 months ago
  3. Dynamically adding forms to a formset. OOP version. by halfnibble 8 years, 10 months ago
  4. Convert multiple select for m2m to multiple checkboxes in django admin form by abidibo 10 years, 11 months ago
  5. Django admin inline ordering - javascript only implementation by ojhilt 11 years, 3 months ago

Comments

poluz (on May 6, 2009):

Work perfectly for me with django 1.0.2,jquery 1.3.2 and without jquery ui. Thank you very much.

#

Aneon (on May 6, 2009):

That's good to hear. Thanks for the report!

#

Aneon (on May 6, 2009):

I tested drag-n-drop sorting in Django v1.0.2 as well and it seems to work fine.

#

poluz (on May 7, 2009):

I tried the sorting system. The correct order is always saved in the db, but to override the default ordering in the admin (that is alphabetically on the ImageField in my case), I added this subclass to my «inline» model:

class slideshow_img:
    immagine = models.ImageField(upload_to='uploads/portfolio/img/')
    position = models.IntegerField()

    class Meta:
        ordering = ('position',)

#

Aneon (on May 7, 2009):

The javascript should order the table rows at page load based on the position field, regardless of which order they're presented in (on the admin page that is). But I haven't really tested that functionality so you're probably best of by adding class Meta: ordering = ('position',) to your inline model, or it might not work as expected. I've added this to the description example.

#

lelio (on July 9, 2009):

it's don't work with date widget. Same help? Thanks

#

buriy (on November 19, 2009):

doesn't clone calendar widgets properly :(

#

alanwj (on January 6, 2010):

Drag and drop doesn't work for inlines with textareas. Here is a patch to correct the problem:

--- dynamic_inlines_with_sort.js    2010-01-06 00:27:10.757900529 -0800
+++ dynamic_inlines_with_sort.js    2010-01-06 00:32:05.000025067 -0800
@@ -63,7 +63,7 @@
                 items: 'tr:has(td)',
                 tolerance: 'pointer',
                 axis: 'y',
-                cancel: 'input,button,select,a',
+                cancel: 'input,button,select,a,textarea',
                 helper: 'clone',
                 update: function() {
                     update_positions($(this));
@@ -197,6 +197,19 @@
         $(this).attr('name', new_id)
     });

+    // <textarea ...>
+    row.find('textarea').each(function() {
+        // id=...
+        old_id = $(this).attr('id').toString();
+        new_id = old_id.replace(/([^ ]+\-)[0-9]+(\-[^ ]+)/i, "$1" + new_position + "$2");
+        $(this).attr('id', new_id)
+
+        // name=...
+        old_id = $(this).attr('name').toString();
+        new_id = old_id.replace(/([^ ]+\-)[0-9]+(\-[^ ]+)/i, "$1" + new_position + "$2");
+        $(this).attr('name', new_id)
+    });
+
     // <a ...>
     row.find('a').each(function() {
         // id=...

#

alanwj (on January 11, 2010):

One more minor bug I found.

'option' needs to be added to the cancel list. Without it trying to scroll select boxes using arrow keys doesn't work correctly.

#

nemesis (on January 28, 2010):

Really cool man, Thanks! One thing, i'd avoid using the <img> tag in the link_html and use css to add the icons instead.

#

ahojnnes (on February 1, 2010):

works fine except for textareas, which are not "selectable", fix: table.sortable({ items: 'tr:has(td)', tolerance: 'pointer', axis: 'y', cancel: 'input,button,select,a,textarea', helper: 'clone', update: function() { update_positions($(this)); } });

#

gamesbook (on April 12, 2010):

Have all these patches been taken into account in the code? Is the developer planning on addressing the issues raised (e.g. date selectors not working)?

#

asimyuksel (on April 14, 2010):

Hi. This is a very cool snippet. Only problem I have is I cant copy the javascript functions associated with a field.I am enabling the code right under the explanation "Place for special code to re-enable javascript widgets after clone" new_item.find('.autocomplete').each(function() { $(this).triggerHandler('autocomplete'); });

I am using the autocomplete function here

This link

I really need this autocomplete for cloned instances. Is there a way to fix this?

Thanks

#

Please login first before commenting.