diff --git a/doc/source/ref/tables.rst b/doc/source/ref/tables.rst
index 2ee75dca36..1947dc9600 100644
--- a/doc/source/ref/tables.rst
+++ b/doc/source/ref/tables.rst
@@ -49,6 +49,17 @@ The following options can be defined in a ``Meta`` class inside a
.. autoclass:: horizon.tables.base.DataTableOptions
:members:
+FormsetDataTable
+================
+
+You can integrate the :class:`.DataTable` with a Django Formset using one of following classes:
+
+.. autoclass:: horizon.tables.formset.FormsetDataTableMixin
+ :members:
+
+.. autoclass:: horizon.tables.formset.FormsetDataTable
+ :members:
+
Table Components
================
diff --git a/horizon/static/horizon/js/horizon.formset_table.js b/horizon/static/horizon/js/horizon.formset_table.js
new file mode 100644
index 0000000000..584388efc0
--- /dev/null
+++ b/horizon/static/horizon/js/horizon.formset_table.js
@@ -0,0 +1,87 @@
+horizon.formset_table = (function () {
+ 'use strict';
+
+ var module = {};
+
+
+ // go through the whole table and fix the numbering of rows
+ module.reenumerate_rows = function (table, prefix) {
+ var count = 0;
+ var input_name_re = new RegExp('^' + prefix + '-(\\d+|__prefix__)-');
+ var input_id_re = new RegExp('^id_' + prefix + '-(\\d+|__prefix__)-');
+
+ table.find('tbody tr').each(function () {
+ $(this).find('input').each(function () {
+ var input = $(this);
+ input.attr('name', input.attr('name').replace(
+ input_name_re, prefix + '-' + count + '-'));
+ input.attr('id', input.attr('id').replace(
+ input_id_re, 'id_' + prefix + '-' + count + '-'));
+ });
+ count += 1;
+ });
+ $('#id_' + prefix + '-TOTAL_FORMS').val(count);
+ };
+
+ // mark a row as deleted and hide it
+ module.delete_row = function (e) {
+ $(this).closest('tr').hide();
+ $(this).prev('input[name$="-DELETE"]').attr('checked', true);
+ };
+
+ // replace the "Delete" checkboxes with × for deleting rows
+ module.replace_delete = function (where) {
+ where.find('input[name$="-DELETE"]').hide().after(
+ $('×').click(module.delete_row)
+ );
+ };
+
+ // add more empty rows in the flavors table
+ module.add_row = function (table, prefix, empty_row_html) {
+ var new_row = $(empty_row_html);
+ module.replace_delete(new_row);
+ table.find('tbody').append(new_row);
+ module.reenumerate_rows(table, prefix);
+ };
+
+ // prepare all the javascript for formset table
+ module.init = function (prefix, empty_row_html, add_label) {
+
+ var table = $('table#' + prefix);
+
+ module.replace_delete(table);
+
+ // if there are extra empty rows, add the button for new rows
+ if (add_label) {
+ var button = $('' +
+ add_label + '');
+ table.find('tfoot td').append(button);
+ button.click(function () {
+ module.add_row(table, prefix, empty_row_html);
+ });
+ }
+
+ // if the formset is not empty and has no errors,
+ // delete the empty extra rows from the end
+ var initial_forms = +$('#id_' + prefix + '-INITIAL_FORMS').val();
+ var total_forms = +$('#id_' + prefix + '-TOTAL_FORMS').val();
+
+ if (table.find('tbody tr').length > 1 &&
+ table.find('tbody td.error').length === 0 &&
+ total_forms > initial_forms) {
+ table.find('tbody tr').each(function (index) {
+ if (index >= initial_forms) {
+ $(this).remove();
+ }
+ });
+ module.reenumerate_rows(table, prefix);
+ $('#id_' + prefix + '-INITIAL_FORMS').val(
+ $('#id_' + prefix + '-TOTAL_FORMS').val());
+ }
+
+ // enable tooltips
+ table.find('td.error[title]').tooltip();
+ };
+
+ return module;
+} ());
diff --git a/horizon/static/horizon/tests/jshint.js b/horizon/static/horizon/tests/jshint.js
index e4be8a90fc..b6d705a473 100644
--- a/horizon/static/horizon/tests/jshint.js
+++ b/horizon/static/horizon/tests/jshint.js
@@ -25,6 +25,7 @@ horizon.addInitFunction(function () {
jsHintTest('horizon.d3piechart.js', '/static/horizon/js/horizon.d3piechart.js', config);
jsHintTest('horizon.firewalls.js', '/static/horizon/js/horizon.firewalls.js', config);
jsHintTest('horizon.forms.js', '/static/horizon/js/horizon.forms.js', config);
+ jsHintTest('horizon.formset_table.js', '/static/horizon/js/horizon.formset_table.js', config);
jsHintTest('horizon.heattop.js', '/static/horizon/js/horizon.heattop.js', config);
jsHintTest('horizon.instances.js', '/static/horizon/js/horizon.instances.js', config);
jsHintTest('horizon.js', '/static/horizon/js/horizon.js', config);
diff --git a/horizon/static/horizon/tests/tables.js b/horizon/static/horizon/tests/tables.js
index cbccdd905b..15dbd315a1 100644
--- a/horizon/static/horizon/tests/tables.js
+++ b/horizon/static/horizon/tests/tables.js
@@ -47,3 +47,60 @@ test("Footer count update", function () {
horizon.datatables.update_footer_count(table);
notEqual(table_count.text().indexOf('5 items'), -1, "Count correct after adding two rows");
});
+
+test("Formset reenumerate rows", function () {
+ var html = $('#formset');
+ var table = html.find('table');
+ var input = table.find('tbody tr#flavors__row__14 input').first();
+
+ input.attr('id', 'id_flavors-3-name');
+ horizon.formset_table.reenumerate_rows(table, 'flavors');
+ equal(input.attr('id'), 'id_flavors-0-name', "Enumerate old rows ids");
+ input.attr('id', 'id_flavors-__prefix__-name');
+ horizon.formset_table.reenumerate_rows(table, 'flavors');
+ equal(input.attr('id'), 'id_flavors-0-name', "Enumerate new rows ids");
+});
+
+test("Formset delete row", function () {
+ var html = $('#formset');
+ var table = html.find('table');
+ var row = table.find('tbody tr').first();
+ var input = row.find('input#id_flavors-0-DELETE');
+
+ equal(row.css("display"), 'table-row');
+ equal(input.attr('checked'), undefined);
+ horizon.formset_table.replace_delete(row);
+ var x = input.next('a');
+ horizon.formset_table.delete_row.call(x);
+ equal(row.css("display"), 'none');
+ equal(input.attr('checked'), 'checked');
+});
+
+test("Formset add row", function() {
+ var html = $('#formset');
+ var table = html.find('table');
+ var empty_row_html = '
';
+
+ equal(table.find('tbody tr').length, 3);
+ equal(html.find('#id_flavors-TOTAL_FORMS').val(), 3);
+ horizon.formset_table.add_row(table, 'flavors', empty_row_html);
+ equal(table.find('tbody tr').length, 4);
+ equal(table.find('tbody tr:last input').attr('id'), 'id_flavors-3-name');
+ equal(html.find('#id_flavors-TOTAL_FORMS').val(), 4);
+});
+
+test("Init formset table", function() {
+ var html = $('#formset');
+ var table = html.find('table');
+
+ horizon.formset_table.init('flavors', '', 'Add row');
+ equal(table.find('tfoot tr a').html(), 'Add row');
+});
+
+test("Init formset table -- no add", function() {
+ var html = $('#formset');
+ var table = html.find('table');
+
+ horizon.formset_table.init('flavors', '', '');
+ equal(table.find('tfoot tr a').length, 0);
+});
diff --git a/horizon/tables/formset.py b/horizon/tables/formset.py
new file mode 100644
index 0000000000..c6dd24cf40
--- /dev/null
+++ b/horizon/tables/formset.py
@@ -0,0 +1,178 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import itertools
+import logging
+import sys
+
+from django import template
+from django.template import loader
+from django.utils import datastructures
+
+from horizon.tables import base as horizon_tables
+
+
+LOG = logging.getLogger(__name__)
+
+
+class FormsetCell(horizon_tables.Cell):
+ """A DataTable cell that knows about its field from the formset."""
+
+ def __init__(self, *args, **kwargs):
+ super(FormsetCell, self).__init__(*args, **kwargs)
+ try:
+ self.field = (self.row.form or {})[self.column.name]
+ except KeyError:
+ self.field = None
+ else:
+ if self.field.errors:
+ self.attrs['class'] = (self.attrs.get('class', '') +
+ ' error control-group')
+ self.attrs['title'] = ' '.join(
+ unicode(error) for error in self.field.errors)
+
+
+class FormsetRow(horizon_tables.Row):
+ """A DataTable row that knows about its form from the formset."""
+
+ template_path = 'horizon/common/_formset_table_row.html'
+
+ def __init__(self, column, datum, form):
+ self.form = form
+ super(FormsetRow, self).__init__(column, datum)
+ if self.cells == []:
+ # We need to be able to handle empty rows, because there may
+ # be extra empty forms in a formset. The original DataTable breaks
+ # on this, because it sets self.cells to [], but later expects a
+ # SortedDict. We just fill self.cells with empty Cells.
+ cells = []
+ for column in self.table.columns.values():
+ cell = self.table._meta.cell_class(None, column, self)
+ cells.append((column.name or column.auto, cell))
+ self.cells = datastructures.SortedDict(cells)
+
+ def render(self):
+ return loader.render_to_string(self.template_path,
+ {"row": self, "form": self.form})
+
+
+class FormsetDataTableMixin(object):
+ """A mixin for DataTable to support Django Formsets.
+
+ This works the same as the ``FormsetDataTable`` below, but can be used
+ to add to existing DataTable subclasses.
+ """
+ formset_class = None
+
+ def __init__(self, *args, **kwargs):
+ super(FormsetDataTableMixin, self).__init__(*args, **kwargs)
+ self._formset = None
+
+ # Override Meta settings, because we need custom Form and Cell classes,
+ # and also our own template.
+ self._meta.row_class = FormsetRow
+ self._meta.cell_class = FormsetCell
+ self._meta.template = 'horizon/common/_formset_table.html'
+
+ def get_required_columns(self):
+ """Lists names of columns that have required fields."""
+ required_columns = []
+ if self.formset_class:
+ empty_form = self.get_formset().empty_form
+ for column in self.columns.values():
+ field = empty_form.fields.get(column.name)
+ if field and field.required:
+ required_columns.append(column.name)
+ return required_columns
+
+ def _get_formset_data(self):
+ """Formats the self.filtered_data in a way suitable for a formset."""
+ data = []
+ for datum in self.filtered_data:
+ form_data = {}
+ for column in self.columns.values():
+ value = column.get_data(datum)
+ form_data[column.name] = value
+ form_data['id'] = self.get_object_id(datum)
+ data.append(form_data)
+ return data
+
+ def get_formset(self):
+ """Provide the formset corresponding to this DataTable.
+
+ Use this to validate the formset and to get the submitted data back.
+ """
+ if self._formset is None:
+ self._formset = self.formset_class(
+ self.request.POST or None,
+ initial=self._get_formset_data(),
+ prefix=self._meta.name)
+ return self._formset
+
+ def get_empty_row(self):
+ """Return a row with no data, for adding at the end of the table."""
+ return self._meta.row_class(self, None, self.get_formset().empty_form)
+
+ def get_rows(self):
+ """Return the row data for this table broken out by columns.
+
+ The row objects get an additional ``form`` parameter, with the
+ formset form corresponding to that row.
+ """
+ try:
+ rows = []
+ if self.formset_class is None:
+ formset = []
+ else:
+ formset = self.get_formset()
+ formset.is_valid()
+ for datum, form in itertools.izip_longest(self.filtered_data,
+ formset):
+ row = self._meta.row_class(self, datum, form)
+ if self.get_object_id(datum) == self.current_item_id:
+ self.selected = True
+ row.classes.append('current_selected')
+ rows.append(row)
+ except Exception:
+ # Exceptions can be swallowed at the template level here,
+ # re-raising as a TemplateSyntaxError makes them visible.
+ LOG.exception("Error while rendering table rows.")
+ exc_info = sys.exc_info()
+ raise template.TemplateSyntaxError, exc_info[1], exc_info[2]
+ return rows
+
+ def get_object_id(self, datum):
+ # We need to support ``None`` when there are more forms than data.
+ if datum is None:
+ return None
+ return super(FormsetDataTableMixin, self).get_object_id(datum)
+
+
+class FormsetDataTable(FormsetDataTableMixin, horizon_tables.DataTable):
+ """A DataTable with support for Django Formsets.
+
+ Note that :attr:`horizon.tables.DataTableOptions.row_class` and
+ :attr:`horizon.tables.DataTaleOptions.cell_class` are overwritten in this
+ class, so setting them in ``Meta`` has no effect.
+
+ .. attribute:: formset_class
+
+ A classs made with ``django.forms.formsets.formset_factory``
+ containing the definition of the formset to use with this data table.
+
+ The columns that are named the same as the formset fields will be
+ replaced with form widgets in the table. Any hidden fields from the
+ formset will also be included. The fields that are not hidden and
+ don't correspond to any column will not be included in the form.
+ """
diff --git a/horizon/templates/horizon/_scripts.html b/horizon/templates/horizon/_scripts.html
index eb966620f2..72dc8a2ee9 100644
--- a/horizon/templates/horizon/_scripts.html
+++ b/horizon/templates/horizon/_scripts.html
@@ -30,6 +30,7 @@
+
diff --git a/horizon/templates/horizon/common/_formset_table.html b/horizon/templates/horizon/common/_formset_table.html
new file mode 100644
index 0000000000..fa7dc1d452
--- /dev/null
+++ b/horizon/templates/horizon/common/_formset_table.html
@@ -0,0 +1,44 @@
+{% extends 'horizon/common/_data_table.html' %}
+{% load i18n %}
+
+{% block table_columns %}
+ {% if not table.is_browser_table %}
+
+ {% for column in columns %}
+
{{ column }}
+ {% endfor %}
+
+ {% endif %}
+{% endblock table_columns %}
+
+{% block table %}
+ {% with table.get_formset as formset %}
+ {{ formset.management_form }}
+ {% if formset.non_field_errors %}
+