=== modified file 'dashboard_app/static/js/filter-edit.js'
--- dashboard_app/static/js/filter-edit.js	2012-08-20 23:55:03 +0000
+++ dashboard_app/static/js/filter-edit.js	2012-09-09 23:30:23 +0000
@@ -1,4 +1,3 @@
-var row_number;
 $(function () {
 function updateTestCasesFromTest() {
     var test_id=$("#id_test option:selected").html();
@@ -22,34 +21,16 @@
         select.attr('disabled', 'disabled');
     }
 };
+
 $("#id_test").change(updateTestCasesFromTest);
-row_number = $("#attribute-table tbody tr").size();
-$("#add-attribute").click(
-    function (e) {
-        e.preventDefault();
-        var body = $("#attribute-table tbody");
-        var row = $("#template-row").clone(true, true);
-        row.show();
-        row.find('.key').attr('id', 'id_attribute_key_' + row_number);
-        row.find('.value').attr('id', 'id_attribute_value_' + row_number);
-        row.find('.key').attr('name', 'attribute_key_' + row_number);
-        row.find('.value').attr('name', 'attribute_value_' + row_number);
-        row_number += 1;
-        body.append(row);
-        row.find(".key").autocomplete(keyAutocompleteConfig);
-        row.find(".value").autocomplete(valueAutocompleteConfig);
-    });
-$("a.delete-row").click(
-    function (e) {
-        e.preventDefault();
-        $(this).closest('tr').remove();
-    });
-var keyAutocompleteConfig = {
+
+var nameAutocompleteConfig = {
         source: attr_name_completion_url
     };
+
 var valueAutocompleteConfig = {
         source: function (request, response) {
-            var attrName = this.element.closest('tr').find('input.key').val();
+            var attrName = this.element.closest('tr').find('.name input').val();
             $.getJSON(
                 attr_value_completion_url,
                 {
@@ -62,6 +43,21 @@
             );
         }
     };
-$("tbody .key").autocomplete(keyAutocompleteConfig);
-$("tbody .value").autocomplete(valueAutocompleteConfig);
+
+$("tbody .name input").autocomplete(nameAutocompleteConfig);
+$("tbody .value input").autocomplete(valueAutocompleteConfig);
+
+$("#attributes-table tbody tr").formset(
+    {
+        formTemplate: '#id_attributes_empty_form',
+        prefix: "attributes",
+        addText: "Add a required attribute",
+        added: function(row) {
+            row.find(".name input").unbind();
+            row.find(".name input").autocomplete(nameAutocompleteConfig);
+            row.find(".value input").unbind();
+            row.find(".value input").autocomplete(valueAutocompleteConfig);
+        }
+    });
+
 });
\ No newline at end of file

=== added file 'dashboard_app/static/js/jquery.formset.js'
--- dashboard_app/static/js/jquery.formset.js	1970-01-01 00:00:00 +0000
+++ dashboard_app/static/js/jquery.formset.js	2012-09-07 00:53:52 +0000
@@ -0,0 +1,206 @@
+/**
+ * jQuery Formset 1.3-pre
+ * @author Stanislaus Madueke (stan DOT madueke AT gmail DOT com)
+ * @requires jQuery 1.2.6 or later
+ *
+ * Copyright (c) 2009, Stanislaus Madueke
+ * All rights reserved.
+ *
+ * Licensed under the New BSD License
+ * See: http://www.opensource.org/licenses/bsd-license.php
+ */
+;(function($) {
+    $.fn.formset = function(opts)
+    {
+        var options = $.extend({}, $.fn.formset.defaults, opts),
+            flatExtraClasses = options.extraClasses.join(' '),
+            totalForms = $('#id_' + options.prefix + '-TOTAL_FORMS'),
+            maxForms = $('#id_' + options.prefix + '-MAX_NUM_FORMS'),
+            childElementSelector = 'input,select,textarea,label,div',
+            $$ = $(this),
+
+            applyExtraClasses = function(row, ndx) {
+                if (options.extraClasses) {
+                    row.removeClass(flatExtraClasses);
+                    row.addClass(options.extraClasses[ndx % options.extraClasses.length]);
+                }
+            },
+
+            updateElementIndex = function(elem, prefix, ndx) {
+                var idRegex = new RegExp(prefix + '-(\\d+|__prefix__)-'),
+                    replacement = prefix + '-' + ndx + '-';
+                if (elem.attr("for")) elem.attr("for", elem.attr("for").replace(idRegex, replacement));
+                if (elem.attr('id')) elem.attr('id', elem.attr('id').replace(idRegex, replacement));
+                if (elem.attr('name')) elem.attr('name', elem.attr('name').replace(idRegex, replacement));
+            },
+
+            hasChildElements = function(row) {
+                return row.find(childElementSelector).length > 0;
+            },
+
+            showAddButton = function() {
+                return maxForms.length == 0 ||   // For Django versions pre 1.2
+                    (maxForms.val() == '' || (maxForms.val() - totalForms.val() > 0))
+            },
+
+            insertDeleteLink = function(row) {
+                if (row.is('TR')) {
+                    // If the forms are laid out in table rows, insert
+                    // the remove button into the last table cell:
+                    row.children(':last').append('<a class="' + options.deleteCssClass +'" href="javascript:void(0)">' + options.deleteText + '</a>');
+                } else if (row.is('UL') || row.is('OL')) {
+                    // If they're laid out as an ordered/unordered list,
+                    // insert an <li> after the last list item:
+                    row.append('<li><a class="' + options.deleteCssClass + '" href="javascript:void(0)">' + options.deleteText +'</a></li>');
+                } else {
+                    // Otherwise, just insert the remove button as the
+                    // last child element of the form's container:
+                    row.append('<a class="' + options.deleteCssClass + '" href="javascript:void(0)">' + options.deleteText +'</a>');
+                }
+                row.find('a.' + options.deleteCssClass).click(function() {
+                    var row = $(this).parents('.' + options.formCssClass),
+                        del = row.find('input:hidden[id $= "-DELETE"]'),
+                        buttonRow = row.siblings("a." + options.addCssClass + ', .' + options.formCssClass + '-add'),
+                        forms;
+                    if (del.length) {
+                        // We're dealing with an inline formset.
+                        // Rather than remove this form from the DOM, we'll mark it as deleted
+                        // and hide it, then let Django handle the deleting:
+                        del.val('on');
+                        row.hide();
+                        forms = $('.' + options.formCssClass).not(':hidden');
+                    } else {
+                        row.remove();
+                        // Update the TOTAL_FORMS count:
+                        forms = $('.' + options.formCssClass).not('.formset-custom-template');
+                        totalForms.val(forms.length);
+                    }
+                    for (var i=0, formCount=forms.length; i<formCount; i++) {
+                        // Apply `extraClasses` to form rows so they're nicely alternating:
+                        applyExtraClasses(forms.eq(i), i);
+                        if (!del.length) {
+                            // Also update names and IDs for all child controls (if this isn't
+                            // a delete-able inline formset) so they remain in sequence:
+                            forms.eq(i).find(childElementSelector).each(function() {
+                                updateElementIndex($(this), options.prefix, i);
+                            });
+                        }
+                    }
+                    // Check if we need to show the add button:
+                    if (buttonRow.is(':hidden') && showAddButton()) buttonRow.show();
+                    // If a post-delete callback was provided, call it with the deleted form:
+                    if (options.removed) options.removed(row);
+                    return false;
+                });
+            };
+
+        $$.each(function(i) {
+            var row = $(this),
+                del = row.find('input:checkbox[id $= "-DELETE"]');
+            if (del.length) {
+                // If you specify "can_delete = True" when creating an inline formset,
+                // Django adds a checkbox to each form in the formset.
+                // Replace the default checkbox with a hidden field:
+                if (del.is(':checked')) {
+                    // If an inline formset containing deleted forms fails validation, make sure
+                    // we keep the forms hidden (thanks for the bug report and suggested fix Mike)
+                    del.before('<input type="hidden" name="' + del.attr('name') +'" id="' + del.attr('id') +'" value="on" />');
+                    row.hide();
+                } else {
+                    del.before('<input type="hidden" name="' + del.attr('name') +'" id="' + del.attr('id') +'" />');
+                }
+                // Hide any labels associated with the DELETE checkbox:
+                $('label[for="' + del.attr('id') + '"]').hide();
+                del.remove();
+            }
+            if (hasChildElements(row)) {
+                row.addClass(options.formCssClass);
+                if (row.is(':visible')) {
+                    insertDeleteLink(row);
+                    applyExtraClasses(row, i);
+                }
+            }
+        });
+
+        if ($$.length) {
+            var hideAddButton = !showAddButton(),
+                addButton, template;
+            if (options.formTemplate) {
+                // If a form template was specified, we'll clone it to generate new form instances:
+                template = (options.formTemplate instanceof $) ? options.formTemplate : $(options.formTemplate);
+                template.removeAttr('id').addClass(options.formCssClass + ' formset-custom-template');
+                template.find(childElementSelector).each(function() {
+                    updateElementIndex($(this), options.prefix, '__prefix__');
+                });
+                insertDeleteLink(template);
+            } else {
+                // Otherwise, use the last form in the formset; this works much better if you've got
+                // extra (>= 1) forms (thnaks to justhamade for pointing this out):
+                template = $('.' + options.formCssClass + ':last').clone(true).removeAttr('id');
+                template.find('input:hidden[id $= "-DELETE"]').remove();
+                // Clear all cloned fields, except those the user wants to keep (thanks to brunogola for the suggestion):
+                template.find(childElementSelector).not(options.keepFieldValues).each(function() {
+                    var elem = $(this);
+                    // If this is a checkbox or radiobutton, uncheck it.
+                    // This fixes Issue 1, reported by Wilson.Andrew.J:
+                    if (elem.is('input:checkbox') || elem.is('input:radio')) {
+                        elem.attr('checked', false);
+                    } else {
+                        elem.val('');
+                    }
+                });
+            }
+            // FIXME: Perhaps using $.data would be a better idea?
+            options.formTemplate = template;
+
+            if ($$.attr('tagName') == 'TR') {
+                // If forms are laid out as table rows, insert the
+                // "add" button in a new table row:
+                var numCols = $$.eq(0).children().length,   // This is a bit of an assumption :|
+                    buttonRow = $('<tr><td colspan="' + numCols + '"><a class="' + options.addCssClass + '" href="javascript:void(0)">' + options.addText + '</a></tr>')
+                                .addClass(options.formCssClass + '-add');
+                $$.parent().append(buttonRow);
+                if (hideAddButton) buttonRow.hide();
+                addButton = buttonRow.find('a');
+            } else {
+                // Otherwise, insert it immediately after the last form:
+                $$.filter(':last').after('<a class="' + options.addCssClass + '" href="javascript:void(0)">' + options.addText + '</a>');
+                addButton = $$.filter(':last').next();
+                if (hideAddButton) addButton.hide();
+            }
+            addButton.click(function() {
+                var formCount = parseInt(totalForms.val()),
+                    row = options.formTemplate.clone(true).removeClass('formset-custom-template'),
+                    buttonRow = $($(this).parents('tr.' + options.formCssClass + '-add').get(0) || this);
+                applyExtraClasses(row, formCount);
+                row.insertBefore(buttonRow).show();
+                row.find(childElementSelector).each(function() {
+                    updateElementIndex($(this), options.prefix, formCount);
+                });
+                totalForms.val(formCount + 1);
+                // Check if we've exceeded the maximum allowed number of forms:
+                if (!showAddButton()) buttonRow.hide();
+                // If a post-add callback was supplied, call it with the added form:
+                if (options.added) options.added(row);
+                return false;
+            });
+        }
+
+        return $$;
+    }
+
+    /* Setup plugin defaults */
+    $.fn.formset.defaults = {
+        prefix: 'form',                  // The form prefix for your django formset
+        formTemplate: null,              // The jQuery selection cloned to generate new form instances
+        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
+        deleteCssClass: 'delete-row',    // CSS class applied to the delete link
+        formCssClass: 'dynamic-form',    // CSS class applied to each form in a formset
+        extraClasses: [],                // Additional CSS classes, which will be applied to each form in turn
+        keepFieldValues: '',             // jQuery selector for fields whose values should be kept when the form is cloned
+        added: null,                     // Function called each time a new form is added
+        removed: null                    // Function called each time a form is deleted
+    };
+})(jQuery)

=== modified file 'dashboard_app/templates/dashboard_app/filter_form.html'
--- dashboard_app/templates/dashboard_app/filter_form.html	2012-09-04 02:13:52 +0000
+++ dashboard_app/templates/dashboard_app/filter_form.html	2012-09-09 23:30:23 +0000
@@ -34,7 +34,8 @@
         Attributes:
       </dt>
       <dd>
-        <table id="attribute-table">
+        {% with form.attributes_formset as formset %}
+        <table id="attributes-table">
           <thead>
             <tr>
               <th>
@@ -45,46 +46,40 @@
               </th>
             </tr>
           </thead>
+          {{ formset.management_form }}
           <tbody>
-            {% for attr in form.attributes %}
-            <tr>
-              <td>
-                <input class="key"
-                       id="id_attribute_key_{{ forloop.counter0 }}"
-                       name="attribute_key_{{ forloop.counter0 }}"
-                       value="{{attr.0}}" />
-              </td>
-              <td>
-                <input class="value"
-                       id="id_attribute_value_{{ forloop.counter0 }}"
-                       name="attribute_value_{{ forloop.counter0 }}"
-                       value="{{attr.1}}" />
-              </td>
-              <td>
-                <a href="#" class="delete-row">remove</a>
-              </td>
+            {% for form in formset %}
+            <tr>
+              <td class="name">
+                {{ form.name }}
+              </td>
+              <td class="value">
+                {{ form.value }}
+              </td>
+              <td>
+              </td>
+            </tr>
+            {% empty %}
+            <tr>
             </tr>
             {% endfor %}
           </tbody>
           <tfoot>
-            <tr style="display:none" id="template-row">
-              <td>
-                <input class="key" />
-              </td>
-              <td>
-                <input class="value" />
-              </td>
-              <td>
-                <a href="#" class="delete-row">remove</a>
-              </td>
-            </tr>
-            <tr>
-              <td colspan="2">
-                <a id="add-attribute" href="#">Add a required attribute</a>
-              </td>
-            </tr>
+            {% with formset.empty_form as form %}
+            <tr style="display:none" id="id_attributes_empty_form">
+              <td class="name">
+                {{ form.name }}
+              </td>
+              <td class="value">
+                {{ form.value }}
+              </td>
+              <td>
+              </td>
+            </tr>
+            {% endwith %}
           </tfoot>
         </table>
+        {% endwith %}
         <br /><span class="helptext">
           A filter can be limited to test runs with particular values for particular <b>attributes</b>.
         </span>

=== modified file 'dashboard_app/views.py'
--- dashboard_app/views.py	2012-09-09 23:05:58 +0000
+++ dashboard_app/views.py	2012-09-09 23:13:10 +0000
@@ -34,6 +34,7 @@
 from django.db.models.manager import Manager
 from django.db.models.query import QuerySet
 from django import forms
+from django.forms.formsets import formset_factory
 from django.http import Http404, HttpResponse, HttpResponseRedirect
 from django.shortcuts import render_to_response, redirect, get_object_or_404
 from django.template import RequestContext, loader
@@ -739,10 +740,17 @@
 var attr_name_completion_url = "{% url dashboard_app.views.filter_attr_name_completion_json %}";
 var attr_value_completion_url = "{% url dashboard_app.views.filter_attr_value_completion_json %}";
 </script>
+<script type="text/javascript" src="{{ STATIC_URL }}dashboard_app/js/jquery.formset.js"></script>
 <script type="text/javascript" src="{{ STATIC_URL }}dashboard_app/js/filter-edit.js"></script>
 '''
 
 
+class AttributesForm(forms.Form):
+    name = forms.CharField(max_length=1024)
+    value = forms.CharField(max_length=1024)
+
+AttributesFormSet = formset_factory(AttributesForm, extra=0)
+
 class TestRunFilterForm(forms.ModelForm):
     class Meta:
         model = TestRunFilter
@@ -779,20 +787,37 @@
         instance = super(TestRunFilterForm, self).save(commit=commit, **kwargs)
         if commit:
             instance.attributes.all().delete()
-            for (name, value) in self.attributes:
-                instance.attributes.create(name=name, value=value)
+            for a in self.attributes_formset.cleaned_data:
+                instance.attributes.create(name=a['name'], value=a['value'])
         return instance
 
+    def is_valid(self):
+        return super(TestRunFilterForm, self).is_valid() and \
+               self.attributes_formset.is_valid()
+
     @property
     def summary_data(self):
         data = self.cleaned_data.copy()
-        data['attributes'] = self.attributes
+        data['attributes'] = [
+            (d['name'], d['value']) for d in self.attributes_formset.cleaned_data]
         return data
 
     def __init__(self, user, *args, **kwargs):
         super(TestRunFilterForm, self).__init__(*args, **kwargs)
         self.instance.owner = user
-        self.fields['bundle_streams'].queryset = BundleStream.objects.accessible_by_principal(user)
+        kwargs.pop('instance', None)
+        if self.instance.pk:
+            initial = []
+            for attr in self.instance.attributes.all():
+                initial.append({
+                    'name': attr.name,
+                    'value': attr.value,
+                    })
+            kwargs['initial'] = initial
+        kwargs['prefix'] = 'attributes'
+        self.attributes_formset = AttributesFormSet(*args, **kwargs)
+        self.fields['bundle_streams'].queryset = \
+            BundleStream.objects.accessible_by_principal(user).order_by('pathname')
         self.fields['name'].validators.append(self.validate_name)
         test = self['test'].value()
         if test:
@@ -801,27 +826,11 @@
             test = Test.objects.get(pk=test)
             self.fields['test_case'].queryset = TestCase.objects.filter(test=test).order_by('test_case_id')
 
-    @property
-    def attributes(self):
-        if not self.is_bound and self.instance.pk:
-            return self.instance.attributes.values_list('name', 'value')
-        else:
-            attributes = []
-            for (var, value) in self.data.iteritems():
-                if var.startswith('attribute_key_'):
-                    index = int(var[len('attribute_key_'):])
-                    attr_value = self.data['attribute_value_' + str(index)]
-                    attributes.append((index, value, attr_value))
-
-            attributes.sort()
-            attributes = [a[1:] for a in attributes]
-            return attributes
-
     def get_test_runs(self, user):
         assert self.is_valid(), self.errors
         filter = self.save(commit=False)
         return filter.get_test_runs_impl(
-            user, self.cleaned_data['bundle_streams'], self.attributes)
+            user, self.cleaned_data['bundle_streams'], self.summary_data['attributes'])
 
 
 def filter_form(request, bread_crumb_trail, instance=None):
@@ -846,6 +855,7 @@
                     }, RequestContext(request))
     else:
         form = TestRunFilterForm(request.user, instance=instance)
+
     return render_to_response(
         'dashboard_app/filter_add.html', {
             'bread_crumb_trail': bread_crumb_trail,

