Django Formset support in DataTable
Add FormsetDataTable and FormsetDataTableMixin classes that allow you to use a Django Formset with your DataTable, making it possible to edit the data displayed. Implements: blueprint formset-data-table Change-Id: I636fa3ae9847f1700eae0fb5293fa97683ba2218
This commit is contained in:
parent
fdf920e714
commit
ba1b9007d4
@ -49,6 +49,17 @@ The following options can be defined in a ``Meta`` class inside a
|
|||||||
.. autoclass:: horizon.tables.base.DataTableOptions
|
.. autoclass:: horizon.tables.base.DataTableOptions
|
||||||
:members:
|
: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
|
Table Components
|
||||||
================
|
================
|
||||||
|
|
||||||
|
87
horizon/static/horizon/js/horizon.formset_table.js
Normal file
87
horizon/static/horizon/js/horizon.formset_table.js
Normal file
@ -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(
|
||||||
|
$('<a href="#" class="close">×</a>').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 = $('<a href="#" class="btn btn-small pull-right">' +
|
||||||
|
add_label + '</a>');
|
||||||
|
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;
|
||||||
|
} ());
|
@ -25,6 +25,7 @@ horizon.addInitFunction(function () {
|
|||||||
jsHintTest('horizon.d3piechart.js', '/static/horizon/js/horizon.d3piechart.js', config);
|
jsHintTest('horizon.d3piechart.js', '/static/horizon/js/horizon.d3piechart.js', config);
|
||||||
jsHintTest('horizon.firewalls.js', '/static/horizon/js/horizon.firewalls.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.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.heattop.js', '/static/horizon/js/horizon.heattop.js', config);
|
||||||
jsHintTest('horizon.instances.js', '/static/horizon/js/horizon.instances.js', config);
|
jsHintTest('horizon.instances.js', '/static/horizon/js/horizon.instances.js', config);
|
||||||
jsHintTest('horizon.js', '/static/horizon/js/horizon.js', config);
|
jsHintTest('horizon.js', '/static/horizon/js/horizon.js', config);
|
||||||
|
@ -47,3 +47,60 @@ test("Footer count update", function () {
|
|||||||
horizon.datatables.update_footer_count(table);
|
horizon.datatables.update_footer_count(table);
|
||||||
notEqual(table_count.text().indexOf('5 items'), -1, "Count correct after adding two rows");
|
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 = '<tr><td><input id="id_flavors-__prefix__-name" name="flavors-__prefix__-name"></td></tr>';
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
178
horizon/tables/formset.py
Normal file
178
horizon/tables/formset.py
Normal file
@ -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.
|
||||||
|
"""
|
@ -30,6 +30,7 @@
|
|||||||
<script src='{{ STATIC_URL }}horizon/js/horizon.communication.js' type='text/javascript' charset='utf-8'></script>
|
<script src='{{ STATIC_URL }}horizon/js/horizon.communication.js' type='text/javascript' charset='utf-8'></script>
|
||||||
<script src='{{ STATIC_URL }}horizon/js/horizon.cookies.js' type='text/javascript' charset='utf-8'></script>
|
<script src='{{ STATIC_URL }}horizon/js/horizon.cookies.js' type='text/javascript' charset='utf-8'></script>
|
||||||
<script src='{{ STATIC_URL }}horizon/js/horizon.forms.js' type='text/javascript' charset='utf-8'></script>
|
<script src='{{ STATIC_URL }}horizon/js/horizon.forms.js' type='text/javascript' charset='utf-8'></script>
|
||||||
|
<script src='{{ STATIC_URL }}horizon/js/horizon.formset_table.js' type='text/javascript' charset='utf-8'></script>
|
||||||
<script src='{{ STATIC_URL }}horizon/js/horizon.instances.js' type='text/javascript' charset='utf-8'></script>
|
<script src='{{ STATIC_URL }}horizon/js/horizon.instances.js' type='text/javascript' charset='utf-8'></script>
|
||||||
<script src='{{ STATIC_URL }}horizon/js/horizon.messages.js' type='text/javascript' charset='utf-8'></script>
|
<script src='{{ STATIC_URL }}horizon/js/horizon.messages.js' type='text/javascript' charset='utf-8'></script>
|
||||||
<script src='{{ STATIC_URL }}horizon/js/horizon.modals.js' type='text/javascript' charset='utf-8'></script>
|
<script src='{{ STATIC_URL }}horizon/js/horizon.modals.js' type='text/javascript' charset='utf-8'></script>
|
||||||
|
44
horizon/templates/horizon/common/_formset_table.html
Normal file
44
horizon/templates/horizon/common/_formset_table.html
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{% extends 'horizon/common/_data_table.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block table_columns %}
|
||||||
|
{% if not table.is_browser_table %}
|
||||||
|
<tr>
|
||||||
|
{% for column in columns %}
|
||||||
|
<th {{ column.attr_string|safe }}><span
|
||||||
|
{% if column.name in table.get_required_columns %}
|
||||||
|
class="required"
|
||||||
|
{% endif %}
|
||||||
|
>{{ column }}</span></th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock table_columns %}
|
||||||
|
|
||||||
|
{% block table %}
|
||||||
|
{% with table.get_formset as formset %}
|
||||||
|
{{ formset.management_form }}
|
||||||
|
{% if formset.non_field_errors %}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
{{ formset.non_field_errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{{ block.super }}
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
($ || addHorizonLoadEvent)(function () {
|
||||||
|
// prepare the js-enabled parts of the formset data table
|
||||||
|
var prefix = '{{ table.name|escapejs }}';
|
||||||
|
var empty_row_html = '{% filter escapejs %}{% include "horizon/common/_formset_table_row.html" with row=table.get_empty_row %}{% endfilter %}';
|
||||||
|
{% if table.formset_class.extra %}
|
||||||
|
var add_label = '{% filter escapejs %}{% trans "Add a row" %}{% endfilter %}';
|
||||||
|
{% else %}
|
||||||
|
var add_label = '';
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
horizon.formset_table.init(prefix, empty_row_html, add_label);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock table %}
|
24
horizon/templates/horizon/common/_formset_table_row.html
Normal file
24
horizon/templates/horizon/common/_formset_table_row.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<tr{{ row.attr_string|safe }}>
|
||||||
|
{% for cell in row %}
|
||||||
|
<td{{ cell.attr_string|safe }}>
|
||||||
|
{% if cell.field %}
|
||||||
|
{{ cell.field }}
|
||||||
|
{% else %}
|
||||||
|
{%if cell.wrap_list %}<ul>{% endif %}{{ cell.value }}{%if cell.wrap_list %}</ul>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if forloop.first %}
|
||||||
|
{% for field in row.form.hidden_fields %}
|
||||||
|
{{ field }}
|
||||||
|
{% for error in field.errors %}
|
||||||
|
<span class="help-inline">{{ field.name }}: {{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if row.form.non_field_errors %}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
{{ row.form.non_field_errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
@ -71,6 +71,165 @@
|
|||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<div id="formset" class="table_wrapper">
|
||||||
|
<input id="id_flavors-TOTAL_FORMS" name="flavors-TOTAL_FORMS"
|
||||||
|
type="hidden" value="3"><input id="id_flavors-INITIAL_FORMS"
|
||||||
|
name="flavors-INITIAL_FORMS" type="hidden" value="2"><input id=
|
||||||
|
"id_flavors-MAX_NUM_FORMS" name="flavors-MAX_NUM_FORMS" type=
|
||||||
|
"hidden" value="1000">
|
||||||
|
|
||||||
|
<table id="flavors" class=
|
||||||
|
"table table-bordered table-striped datatable">
|
||||||
|
<thead>
|
||||||
|
<tr class='table_caption'>
|
||||||
|
<th class='table_header' colspan='8'>
|
||||||
|
<h3 class='table_title'>Flavors</h3>
|
||||||
|
|
||||||
|
<div class="table_actions clearfix"></div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<th class="sortable normal_column"><span class=
|
||||||
|
"required">Flavor Name</span></th>
|
||||||
|
|
||||||
|
<th class="sortable normal_column"><span class=
|
||||||
|
"required">VCPU</span></th>
|
||||||
|
|
||||||
|
<th class="sortable normal_column"><span class=
|
||||||
|
"required">RAM (MB)</span></th>
|
||||||
|
|
||||||
|
<th class="sortable normal_column"><span class=
|
||||||
|
"required">Root Disk (GB)</span></th>
|
||||||
|
|
||||||
|
<th class="sortable normal_column"><span>Ephemeral Disk
|
||||||
|
(GB)</span></th>
|
||||||
|
|
||||||
|
<th class="sortable normal_column"><span>Swap Disk
|
||||||
|
(MB)</span></th>
|
||||||
|
|
||||||
|
<th class="sortable normal_column"><span>Max.
|
||||||
|
VMs</span></th>
|
||||||
|
|
||||||
|
<th class="sortable normal_column">
|
||||||
|
<span>Delete</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
<tr class="" data-display="yyy.1" id="flavors__row__14">
|
||||||
|
<td class="sortable normal_column"><input class=
|
||||||
|
"input input-small" id="id_flavors-0-name" maxlength="25"
|
||||||
|
name="flavors-0-name" type="text" value="1"> <input id=
|
||||||
|
"id_flavors-0-id" name="flavors-0-id" type="hidden"
|
||||||
|
value="14"></td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column"><input class=
|
||||||
|
"input number_input_slim" id="id_flavors-0-cpu" name=
|
||||||
|
"flavors-0-cpu" type="number" value="1"></td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column"><input class=
|
||||||
|
"input number_input_slim" id="id_flavors-0-memory" name=
|
||||||
|
"flavors-0-memory" type="number" value="1"></td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column"><input class=
|
||||||
|
"input number_input_slim" id="id_flavors-0-storage" name=
|
||||||
|
"flavors-0-storage" type="number" value="1"></td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column"><input class=
|
||||||
|
"input number_input_slim" id=
|
||||||
|
"id_flavors-0-ephemeral_disk" name=
|
||||||
|
"flavors-0-ephemeral_disk" type="number"></td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column"><input class=
|
||||||
|
"input number_input_slim" id="id_flavors-0-swap_disk"
|
||||||
|
name="flavors-0-swap_disk" type="number"></td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column">-</td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column"><input id=
|
||||||
|
"id_flavors-0-DELETE" name="flavors-0-DELETE" type=
|
||||||
|
"checkbox"></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr class="" data-display="yyy.2" id="flavors__row__15">
|
||||||
|
<td class="sortable normal_column"><input class=
|
||||||
|
"input input-small" id="id_flavors-1-name" maxlength="25"
|
||||||
|
name="flavors-1-name" type="text" value="2"> <input id=
|
||||||
|
"id_flavors-1-id" name="flavors-1-id" type="hidden"
|
||||||
|
value="15"></td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column"><input class=
|
||||||
|
"input number_input_slim" id="id_flavors-1-cpu" name=
|
||||||
|
"flavors-1-cpu" type="number" value="2"></td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column"><input class=
|
||||||
|
"input number_input_slim" id="id_flavors-1-memory" name=
|
||||||
|
"flavors-1-memory" type="number" value="2"></td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column"><input class=
|
||||||
|
"input number_input_slim" id="id_flavors-1-storage" name=
|
||||||
|
"flavors-1-storage" type="number" value="2"></td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column"><input class=
|
||||||
|
"input number_input_slim" id=
|
||||||
|
"id_flavors-1-ephemeral_disk" name=
|
||||||
|
"flavors-1-ephemeral_disk" type="number"></td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column"><input class=
|
||||||
|
"input number_input_slim" id="id_flavors-1-swap_disk"
|
||||||
|
name="flavors-1-swap_disk" type="number"></td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column">-</td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column"><input id=
|
||||||
|
"id_flavors-1-DELETE" name="flavors-1-DELETE" type=
|
||||||
|
"checkbox"></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr class="current_selected">
|
||||||
|
<td class="sortable normal_column"><input class=
|
||||||
|
"input input-small" id="id_flavors-2-name" maxlength="25"
|
||||||
|
name="flavors-2-name" type="text"> <input id=
|
||||||
|
"id_flavors-2-id" name="flavors-2-id" type="hidden"></td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column"><input class=
|
||||||
|
"input number_input_slim" id="id_flavors-2-cpu" name=
|
||||||
|
"flavors-2-cpu" type="number"></td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column"><input class=
|
||||||
|
"input number_input_slim" id="id_flavors-2-memory" name=
|
||||||
|
"flavors-2-memory" type="number"></td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column"><input class=
|
||||||
|
"input number_input_slim" id="id_flavors-2-storage" name=
|
||||||
|
"flavors-2-storage" type="number"></td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column"><input class=
|
||||||
|
"input number_input_slim" id=
|
||||||
|
"id_flavors-2-ephemeral_disk" name=
|
||||||
|
"flavors-2-ephemeral_disk" type="number"></td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column"><input class=
|
||||||
|
"input number_input_slim" id="id_flavors-2-swap_disk"
|
||||||
|
name="flavors-2-swap_disk" type="number"></td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column">-</td>
|
||||||
|
|
||||||
|
<td class="sortable normal_column"><input id=
|
||||||
|
"id_flavors-2-DELETE" name="flavors-2-DELETE" type=
|
||||||
|
"checkbox"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="8"><span class="table_count">Displaying 3
|
||||||
|
items</span></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -22,6 +22,7 @@ from django import shortcuts
|
|||||||
from mox import IsA # noqa
|
from mox import IsA # noqa
|
||||||
|
|
||||||
from horizon import tables
|
from horizon import tables
|
||||||
|
from horizon.tables import formset as table_formset
|
||||||
from horizon.tables import views as table_views
|
from horizon.tables import views as table_views
|
||||||
from horizon.test import helpers as test
|
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['my_table_table'].__class__, MyTable)
|
||||||
self.assertEqual(context['table_with_permissions_table'].__class__,
|
self.assertEqual(context['table_with_permissions_table'].__class__,
|
||||||
TableWithPermissions)
|
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)
|
||||||
|
@ -890,6 +890,29 @@ div.table_cell_wrapper {
|
|||||||
}
|
}
|
||||||
#external_links li { margin: 0 0 0 15px; }
|
#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 */
|
/* Forms */
|
||||||
|
|
||||||
form label {
|
form label {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user