Closes #22522: Render colored badges for Custom Field Choices in tables (#22663)

Render select and multiselect custom field values as colored badges in
table views when their associated choices define colors.

For multiselect fields, render all selected values as badges when any
selected choice has a color, using the secondary badge color for
uncolored choices. Preserve comma-separated text when none of the
selected choices has a color.

Add test coverage for colored, uncolored, empty, mixed, and
HTML-sensitive choice values.

Co-authored-by: Martin Hauser <mhauser@netboxlabs.com>
This commit is contained in:
studioussagar 2026-07-28 19:05:30 +05:30 committed by GitHub
parent 749e1b6579
commit eaa2816964
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 145 additions and 2 deletions

View File

@ -539,9 +539,37 @@ class CustomFieldColumn(tables.Column):
if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
return self.customfield.get_choice_label(value)
if value is None:
return self.default
label = self.customfield.get_choice_label(value)
color = self.customfield.get_choice_color(value)
if color:
return mark_safe(
f'<span class="badge text-bg-{escape(color)}">{escape(label)}</span>'
)
return label
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
return ', '.join(self.customfield.get_choice_label(v) for v in value)
if not value:
return ''
has_color = False
parts = []
for v in value:
label = self.customfield.get_choice_label(v)
color = self.customfield.get_choice_color(v)
if color:
has_color = True
parts.append((label, color))
if has_color:
badges = []
for label, color in parts:
badges.append(
f'<span class="badge text-bg-{escape(color or "secondary")}">{escape(label)}</span>'
)
return mark_safe(' '.join(badges))
return ', '.join(label for label, _ in parts)
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
return mark_safe(', '.join(
self._linkify_item(obj) for obj in self.customfield.deserialize(value)

View File

@ -2,8 +2,11 @@ from django.contrib.auth.models import AnonymousUser
from django.template import Context, Template
from django.test import RequestFactory, TestCase
from core.models import ObjectType
from dcim.models import Device, Site
from dcim.tables import DeviceTable
from extras.choices import CustomFieldChoiceColorChoices, CustomFieldTypeChoices
from extras.models import CustomField, CustomFieldChoiceSet
from netbox.tables import NetBoxTable, columns
from utilities.testing import create_tags, create_test_device, create_test_user
@ -119,3 +122,115 @@ class TagColumnTestCase(TestCase):
'table': table
})
template.render(context)
class CustomFieldColumnTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.object_type = ObjectType.objects.get_for_model(Site)
# Choice set containing one colored and two uncolored choices
cls.mixed_choice_set = CustomFieldChoiceSet.objects.create(
name='Mixed Choice Set',
extra_choices=(
('a', 'Option A'),
('b', 'Option B'),
('c', 'Option C'),
),
choice_colors={
'a': CustomFieldChoiceColorChoices.RED,
},
)
cls.select_cf = CustomField.objects.create(
name='select_field',
type=CustomFieldTypeChoices.TYPE_SELECT,
choice_set=cls.mixed_choice_set,
required=False,
)
cls.select_cf.object_types.set([cls.object_type])
cls.multiselect_cf = CustomField.objects.create(
name='multiselect_field',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choice_set=cls.mixed_choice_set,
required=False,
)
cls.multiselect_cf.object_types.set([cls.object_type])
def test_colored_single_select(self):
column = columns.CustomFieldColumn(self.select_cf)
rendered = str(column.render('a'))
self.assertIn('badge', rendered)
self.assertIn('text-bg-red', rendered)
self.assertIn('Option A', rendered)
def test_uncolored_single_select(self):
column = columns.CustomFieldColumn(self.select_cf)
rendered = str(column.render('b'))
self.assertEqual(rendered, 'Option B')
self.assertNotIn('badge', rendered)
def test_empty_multiselect(self):
column = columns.CustomFieldColumn(self.multiselect_cf)
rendered = column.render([])
self.assertEqual(rendered, '')
def test_multiselect_without_selected_colored_choices(self):
column = columns.CustomFieldColumn(self.multiselect_cf)
rendered = str(column.render(['b', 'c']))
self.assertEqual(rendered, 'Option B, Option C')
self.assertNotIn('badge', rendered)
def test_multiselect_with_mixed_colored_choices(self):
column = columns.CustomFieldColumn(self.multiselect_cf)
rendered = str(column.render(['a', 'b']))
self.assertIn('Option A', rendered)
self.assertIn('Option B', rendered)
self.assertIn('text-bg-red', rendered)
self.assertIn('text-bg-secondary', rendered)
self.assertNotIn(',', rendered)
def test_html_sensitive_multiselect_labels(self):
choice_set = CustomFieldChoiceSet.objects.create(
name='HTML Choice Set',
extra_choices=(
('x', '<b>Bold Option</b>'),
('y', "<script>alert('xss')</script>"),
),
choice_colors={
'x': CustomFieldChoiceColorChoices.RED,
},
)
custom_field = CustomField.objects.create(
name='html_multiselect_field',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choice_set=choice_set,
required=False,
)
custom_field.object_types.set([self.object_type])
column = columns.CustomFieldColumn(custom_field)
rendered = str(column.render(['x', 'y']))
self.assertIn('&lt;b&gt;Bold Option&lt;/b&gt;', rendered)
self.assertNotIn('&amp;lt;', rendered)
self.assertIn('&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;', rendered,)
self.assertNotIn('<script>', rendered)
self.assertIn('text-bg-red', rendered)
self.assertIn('text-bg-secondary', rendered)