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 %} +
+ {{ formset.non_field_errors }} +
+ {% endif %} + {% endwith %} + {{ block.super }} + + +{% endblock table %} diff --git a/horizon/templates/horizon/common/_formset_table_row.html b/horizon/templates/horizon/common/_formset_table_row.html new file mode 100644 index 0000000000..5409a0b516 --- /dev/null +++ b/horizon/templates/horizon/common/_formset_table_row.html @@ -0,0 +1,24 @@ + + {% for cell in row %} + + {% if cell.field %} + {{ cell.field }} + {% else %} + {%if cell.wrap_list %}{% endif %} + {% endif %} + {% if forloop.first %} + {% for field in row.form.hidden_fields %} + {{ field }} + {% for error in field.errors %} + {{ field.name }}: {{ error }} + {% endfor %} + {% endfor %} + {% if row.form.non_field_errors %} +
+ {{ row.form.non_field_errors }} +
+ {% endif %} + {% endif %} + + {% endfor %} + diff --git a/horizon/templates/horizon/qunit.html b/horizon/templates/horizon/qunit.html index 95c8dc65a5..d08b05f67b 100644 --- a/horizon/templates/horizon/qunit.html +++ b/horizon/templates/horizon/qunit.html @@ -71,6 +71,165 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Flavors

+ +
+
Flavor NameVCPURAM (MB)Root Disk (GB)Ephemeral Disk + (GB)Swap Disk + (MB)Max. + VMs + Delete
-
-
-
Displaying 3 + items
+
diff --git a/horizon/test/tests/tables.py b/horizon/test/tests/tables.py index db2f0d5597..56a0713e35 100644 --- a/horizon/test/tests/tables.py +++ b/horizon/test/tests/tables.py @@ -22,6 +22,7 @@ from django import shortcuts from mox import IsA # noqa from horizon import tables +from horizon.tables import formset as table_formset from horizon.tables import views as table_views from horizon.test import helpers as test @@ -1087,3 +1088,33 @@ class DataTableViewTests(test.TestCase): self.assertEqual(context['my_table_table'].__class__, MyTable) self.assertEqual(context['table_with_permissions_table'].__class__, TableWithPermissions) + + +class FormsetTableTests(test.TestCase): + + def test_populate(self): + """Create a FormsetDataTable and populate it with data.""" + + class TableForm(forms.Form): + name = forms.CharField() + value = forms.IntegerField() + + TableFormset = forms.formsets.formset_factory(TableForm, extra=0) + + class Table(table_formset.FormsetDataTable): + formset_class = TableFormset + + name = tables.Column('name') + value = tables.Column('value') + + class Meta: + name = 'table' + + table = Table(self.request) + table.data = TEST_DATA_4 + formset = table.get_formset() + self.assertEqual(len(formset), 2) + form = formset[0] + form_data = form.initial + self.assertEqual(form_data['name'], 'object_1') + self.assertEqual(form_data['value'], 2) diff --git a/openstack_dashboard/static/dashboard/less/horizon.less b/openstack_dashboard/static/dashboard/less/horizon.less index edaf069768..d5f59b630b 100644 --- a/openstack_dashboard/static/dashboard/less/horizon.less +++ b/openstack_dashboard/static/dashboard/less/horizon.less @@ -890,6 +890,29 @@ div.table_cell_wrapper { } #external_links li { margin: 0 0 0 15px; } + +/* Formset data tables. */ + +.datatable { + th.narrow { + width: 1em; + } + + input { + padding: 2px 5px; + margin: 0; + } + + th span.required:after { + content: "*"; + font-weight: bold; + line-height: 0; + padding-left: 4px; + color: #3290c0; + } +} + + /* Forms */ form label {