Handle custom column classes; inherit from HTMLElement.

* Fixes bug 960588, allowing custom (and mixed) column classes
    to be handled appropriately in the table metaclass.
  * Reworks Column and Cell to inherit from HTMLElement, making
    them both more consistent and easier to customize.
    Fixes bug 960590.

Change-Id: I88ec6d8d66703f11c508b4c10af439e3b732b3ce
This commit is contained in:
Gabriel Hurley 2012-03-20 13:49:38 -07:00
parent 513a60b69f
commit 8a10e8a5f9
5 changed files with 80 additions and 48 deletions

View File

@ -45,7 +45,7 @@ PALETTE = termcolors.PALETTES[termcolors.DEFAULT_PALETTE]
STRING_SEPARATOR = "__" STRING_SEPARATOR = "__"
class Column(object): class Column(html.HTMLElement):
""" A class which represents a single column in a :class:`.DataTable`. """ A class which represents a single column in a :class:`.DataTable`.
.. attribute:: transform .. attribute:: transform
@ -120,6 +120,16 @@ class Column(object):
A list of functions (often template filters) to be applied to the A list of functions (often template filters) to be applied to the
value of the data for this column prior to output. This is effectively value of the data for this column prior to output. This is effectively
a shortcut for writing a custom ``transform`` function in simple cases. a shortcut for writing a custom ``transform`` function in simple cases.
.. attribute:: classes
An iterable of CSS classes which should be added to this column.
Example: ``classes=('foo', 'bar')``.
.. attribute:: attrs
A dict of HTML attribute strings which should be added to this column.
Example: ``attrs={"data-foo": "bar"}``.
""" """
# Used to retain order when instantiating columns on a table # Used to retain order when instantiating columns on a table
creation_counter = 0 creation_counter = 0
@ -147,7 +157,12 @@ class Column(object):
def __init__(self, transform, verbose_name=None, sortable=False, def __init__(self, transform, verbose_name=None, sortable=False,
link=None, hidden=False, attrs=None, status=False, link=None, hidden=False, attrs=None, status=False,
status_choices=None, empty_value=None, filters=None): status_choices=None, empty_value=None, filters=None,
classes=None):
self.classes = classes or getattr(self, "classes", [])
super(Column, self).__init__()
self.attrs.update(attrs or {})
if callable(transform): if callable(transform):
self.transform = transform self.transform = transform
self.name = transform.__name__ self.name = transform.__name__
@ -172,14 +187,10 @@ class Column(object):
self.creation_counter = Column.creation_counter self.creation_counter = Column.creation_counter
Column.creation_counter += 1 Column.creation_counter += 1
self.attrs = {"classes": []}
self.attrs.update(attrs or {})
# Make sure we have a mutable list.
self.attrs['classes'] = list(self.attrs['classes'])
if self.sortable: if self.sortable:
self.attrs['classes'].append("sortable") self.classes.append("sortable")
if self.hidden: if self.hidden:
self.attrs['classes'].append("hide") self.classes.append("hide")
def __unicode__(self): def __unicode__(self):
return self.verbose_name return self.verbose_name
@ -221,10 +232,6 @@ class Column(object):
self.table._data_cache[self][datum_id] = data self.table._data_cache[self][datum_id] = data
return self.table._data_cache[self][datum_id] return self.table._data_cache[self][datum_id]
def get_classes(self):
""" Returns a flattened string of the column's CSS classes. """
return " ".join(self.attrs['classes'])
def get_link_url(self, datum): def get_link_url(self, datum):
""" Returns the final value for the column's ``link`` property. """ Returns the final value for the column's ``link`` property.
@ -382,15 +389,17 @@ class Row(html.HTMLElement):
% cls.__name__) % cls.__name__)
class Cell(object): class Cell(html.HTMLElement):
""" Represents a single cell in the table. """ """ Represents a single cell in the table. """
def __init__(self, datum, data, column, row, attrs=None): def __init__(self, datum, data, column, row, attrs=None, classes=None):
self.classes = classes or getattr(self, "classes", [])
super(Cell, self).__init__()
self.attrs.update(attrs or {})
self.datum = datum self.datum = datum
self.data = data self.data = data
self.column = column self.column = column
self.row = row self.row = row
self.attrs = {'classes': []}
self.attrs.update(attrs or {})
def __repr__(self): def __repr__(self):
return '<%s: %s, %s>' % (self.__class__.__name__, return '<%s: %s, %s>' % (self.__class__.__name__,
@ -446,12 +455,13 @@ class Cell(object):
else: else:
return "status_unknown" return "status_unknown"
def get_classes(self): def get_default_classes(self):
""" Returns a flattened string of the cell's CSS classes. """ """ Returns a flattened string of the cell's CSS classes. """
union = set(self.attrs['classes']) | set(self.column.attrs['classes']) column_class_string = self.column.get_final_attrs().get('class', "")
classes = set(column_class_string.split(" "))
if self.column.status: if self.column.status:
union.add(self.get_status_class(self.status)) classes.add(self.get_status_class(self.status))
return " ".join(union) return list(classes)
class DataTableOptions(object): class DataTableOptions(object):
@ -583,9 +593,9 @@ class DataTableMetaclass(type):
# Gather columns; this prevents the column from being an attribute # Gather columns; this prevents the column from being an attribute
# on the DataTable class and avoids naming conflicts. # on the DataTable class and avoids naming conflicts.
columns = [(column_name, attrs.pop(column_name)) for \ columns = [(column_name, attrs.pop(column_name)) for
column_name, obj in attrs.items() \ column_name, obj in attrs.items()
if isinstance(obj, opts.column_class)] if issubclass(type(obj), (opts.column_class, Column))]
# add a name attribute to each column # add a name attribute to each column
for column_name, column in columns: for column_name, column in columns:
column.name = column_name column.name = column_name
@ -608,13 +618,13 @@ class DataTableMetaclass(type):
if opts.multi_select: if opts.multi_select:
multi_select = opts.column_class("multi_select", multi_select = opts.column_class("multi_select",
verbose_name="") verbose_name="")
multi_select.attrs = {'classes': ('multi_select_column',)} multi_select.classes.append('multi_select_column')
multi_select.auto = "multi_select" multi_select.auto = "multi_select"
columns.insert(0, ("multi_select", multi_select)) columns.insert(0, ("multi_select", multi_select))
if opts.actions_column: if opts.actions_column:
actions_column = opts.column_class("actions", actions_column = opts.column_class("actions",
verbose_name=_("Actions")) verbose_name=_("Actions"))
actions_column.attrs = {'classes': ('actions_column',)} actions_column.classes.append('actions_column')
actions_column.auto = "actions" actions_column.auto = "actions"
columns.append(("actions", actions_column)) columns.append(("actions", actions_column))
attrs['columns'] = SortedDict(columns) attrs['columns'] = SortedDict(columns)

View File

@ -9,7 +9,7 @@
<thead> <thead>
<tr> <tr>
{% for column in columns %} {% for column in columns %}
<th class="{{ column.get_classes }}">{{ column }}</th> <th {{ column.attr_string|safe }}>{{ column }}</th>
{% endfor %} {% endfor %}
</tr> </tr>
</thead> </thead>

View File

@ -1,3 +1,3 @@
<tr {{ row.attr_string|safe }}> <tr {{ row.attr_string|safe }}>
{% for cell in row %}<td class="{{ cell.get_classes }}">{{ cell.value }}</td>{% endfor %} {% for cell in row %}<td {{ cell.attr_string|safe }}>{{ cell.value }}</td>{% endfor %}
</tr> </tr>

View File

@ -76,6 +76,10 @@ class MyAction(tables.Action):
return shortcuts.redirect('http://example.com/%s' % len(object_ids)) return shortcuts.redirect('http://example.com/%s' % len(object_ids))
class MyColumn(tables.Column):
pass
class MyRow(tables.Row): class MyRow(tables.Row):
ajax = True ajax = True
@ -142,7 +146,7 @@ class MyTable(tables.DataTable):
value = tables.Column('value', value = tables.Column('value',
sortable=True, sortable=True,
link='http://example.com/', link='http://example.com/',
attrs={'classes': ('green', 'blue')}) attrs={'class': 'green blue'})
status = tables.Column('status', link=get_link) status = tables.Column('status', link=get_link)
optional = tables.Column('optional', empty_value='N/A') optional = tables.Column('optional', empty_value='N/A')
excluded = tables.Column('excluded') excluded = tables.Column('excluded')
@ -153,6 +157,7 @@ class MyTable(tables.DataTable):
status_columns = ["status"] status_columns = ["status"]
columns = ('id', 'name', 'value', 'optional', 'status') columns = ('id', 'name', 'value', 'optional', 'status')
row_class = MyRow row_class = MyRow
column_class = MyColumn
table_actions = (MyFilterAction, MyAction, MyBatchAction) table_actions = (MyFilterAction, MyAction, MyBatchAction)
row_actions = (MyAction, MyLinkAction, MyBatchAction, MyToggleAction) row_actions = (MyAction, MyLinkAction, MyBatchAction, MyToggleAction)
@ -172,14 +177,16 @@ class DataTableTests(test.TestCase):
# Column ordering and exclusion. # Column ordering and exclusion.
# This should include auto-columns for multi_select and actions, # This should include auto-columns for multi_select and actions,
# but should not contain the excluded column. # but should not contain the excluded column.
# Additionally, auto-generated columns should use the custom
# column class specified on the table.
self.assertQuerysetEqual(self.table.columns.values(), self.assertQuerysetEqual(self.table.columns.values(),
['<Column: multi_select>', ['<MyColumn: multi_select>',
'<Column: id>', '<Column: id>',
'<Column: name>', '<Column: name>',
'<Column: value>', '<Column: value>',
'<Column: optional>', '<Column: optional>',
'<Column: status>', '<Column: status>',
'<Column: actions>']) '<MyColumn: actions>'])
# Actions (these also test ordering) # Actions (these also test ordering)
self.assertQuerysetEqual(self.table.base_actions.values(), self.assertQuerysetEqual(self.table.base_actions.values(),
['<MyBatchAction: batch>', ['<MyBatchAction: batch>',
@ -199,10 +206,12 @@ class DataTableTests(test.TestCase):
# Auto-generated columns # Auto-generated columns
multi_select = self.table.columns['multi_select'] multi_select = self.table.columns['multi_select']
self.assertEqual(multi_select.auto, "multi_select") self.assertEqual(multi_select.auto, "multi_select")
self.assertEqual(multi_select.get_classes(), "multi_select_column") self.assertEqual(multi_select.get_final_attrs().get('class', ""),
"multi_select_column")
actions = self.table.columns['actions'] actions = self.table.columns['actions']
self.assertEqual(actions.auto, "actions") self.assertEqual(actions.auto, "actions")
self.assertEqual(actions.get_classes(), "actions_column") self.assertEqual(actions.get_final_attrs().get('class', ""),
"actions_column")
def test_table_force_no_multiselect(self): def test_table_force_no_multiselect(self):
class TempTable(MyTable): class TempTable(MyTable):
@ -273,13 +282,13 @@ class DataTableTests(test.TestCase):
self.table = MyTable(self.request, TEST_DATA) self.table = MyTable(self.request, TEST_DATA)
# Verify we retrieve the right columns for headers # Verify we retrieve the right columns for headers
columns = self.table.get_columns() columns = self.table.get_columns()
self.assertQuerysetEqual(columns, ['<Column: multi_select>', self.assertQuerysetEqual(columns, ['<MyColumn: multi_select>',
'<Column: id>', '<Column: id>',
'<Column: name>', '<Column: name>',
'<Column: value>', '<Column: value>',
'<Column: optional>', '<Column: optional>',
'<Column: status>', '<Column: status>',
'<Column: actions>']) '<MyColumn: actions>'])
# Verify we retrieve the right rows from our data # Verify we retrieve the right rows from our data
rows = self.table.get_rows() rows = self.table.get_rows()
self.assertQuerysetEqual(rows, ['<MyRow: my_table__row__1>', self.assertQuerysetEqual(rows, ['<MyRow: my_table__row__1>',
@ -310,21 +319,22 @@ class DataTableTests(test.TestCase):
self.assertEqual(unicode(name_col), "Verbose Name") self.assertEqual(unicode(name_col), "Verbose Name")
# sortable # sortable
self.assertEqual(id_col.sortable, False) self.assertEqual(id_col.sortable, False)
self.assertNotIn("sortable", id_col.get_classes()) self.assertNotIn("sortable", id_col.get_final_attrs().get('class', ""))
self.assertEqual(name_col.sortable, True) self.assertEqual(name_col.sortable, True)
self.assertIn("sortable", name_col.get_classes()) self.assertIn("sortable", name_col.get_final_attrs().get('class', ""))
# hidden # hidden
self.assertEqual(id_col.hidden, True) self.assertEqual(id_col.hidden, True)
self.assertIn("hide", id_col.get_classes()) self.assertIn("hide", id_col.get_final_attrs().get('class', ""))
self.assertEqual(name_col.hidden, False) self.assertEqual(name_col.hidden, False)
self.assertNotIn("hide", name_col.get_classes()) self.assertNotIn("hide", name_col.get_final_attrs().get('class', ""))
# link and get_link_url # link and get_link_url
self.assertIn('href="http://example.com/"', row.cells['value'].value) self.assertIn('href="http://example.com/"', row.cells['value'].value)
self.assertIn('href="/auth/login/"', row.cells['status'].value) self.assertIn('href="/auth/login/"', row.cells['status'].value)
# empty_value # empty_value
self.assertEqual(row3.cells['optional'].value, "N/A") self.assertEqual(row3.cells['optional'].value, "N/A")
# get_classes # classes
self.assertEqual(value_col.get_classes(), "green blue sortable") self.assertEqual(value_col.get_final_attrs().get('class', ""),
"green blue sortable")
# status # status
cell_status = row.cells['status'].status cell_status = row.cells['status'].status
self.assertEqual(cell_status, True) self.assertEqual(cell_status, True)

View File

@ -11,19 +11,22 @@ class HTMLElement(object):
def get_default_classes(self): def get_default_classes(self):
""" """
Returns a list of default classes which should be combined with any Returns an iterable of default classes which should be combined with
other declared classes. any other declared classes.
""" """
return [] return []
def get_default_attrs(self): def get_default_attrs(self):
"""
Returns a dict of default attributes which should be combined with
other declared attributes.
"""
return {} return {}
@property def get_final_attrs(self):
def attr_string(self):
""" """
Returns a flattened string of HTML attributes based on the Returns a dict containing the final attributes of this element
``attrs`` dict provided to the class. which will be rendered.
""" """
final_attrs = copy.copy(self.get_default_attrs()) final_attrs = copy.copy(self.get_default_attrs())
final_attrs.update(self.attrs) final_attrs.update(self.attrs)
@ -31,6 +34,15 @@ class HTMLElement(object):
default = " ".join(self.get_default_classes()) default = " ".join(self.get_default_classes())
defined = self.attrs.get('class', '') defined = self.attrs.get('class', '')
additional = " ".join(getattr(self, "classes", [])) additional = " ".join(getattr(self, "classes", []))
final_classes = " ".join((defined, default, additional)).strip() non_empty = [test for test in (defined, default, additional) if test]
final_classes = " ".join(non_empty).strip()
final_attrs.update({'class': final_classes}) final_attrs.update({'class': final_classes})
return flatatt(final_attrs) return final_attrs
@property
def attr_string(self):
"""
Returns a flattened string of HTML attributes based on the
``attrs`` dict provided to the class.
"""
return flatatt(self.get_final_attrs())