=== modified file 'dashboard_app/static/js/filter-edit.js'
@@ -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'
@@ -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'
@@ -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'
@@ -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,