From 839259ccecdac156c8c0b52678008513fed17758 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Jun 2026 12:17:02 -0400 Subject: [PATCH] Closes #22361: Introduce ArrayAttr UI panel attribute (#22362) --- docs/plugins/development/ui-components.md | 39 ++++++++++++----------- netbox/netbox/tests/test_ui.py | 24 ++++++++++++++ netbox/netbox/ui/attrs.py | 17 ++++++++++ 3 files changed, 62 insertions(+), 18 deletions(-) diff --git a/docs/plugins/development/ui-components.md b/docs/plugins/development/ui-components.md index 45766b201..f148443e3 100644 --- a/docs/plugins/development/ui-components.md +++ b/docs/plugins/development/ui-components.md @@ -135,29 +135,32 @@ panels.ObjectsTablePanel( The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes. -| Class | Description | -|------------------------------------------|--------------------------------------------------| -| `netbox.ui.attrs.AddressAttr` | A physical or mailing address. | -| `netbox.ui.attrs.BooleanAttr` | A boolean value | -| `netbox.ui.attrs.ChoiceAttr` | A selection from a set of choices | -| `netbox.ui.attrs.ColorAttr` | A color expressed in RGB | -| `netbox.ui.attrs.DateTimeAttr` | A date or datetime value | -| `netbox.ui.attrs.GenericForeignKeyAttr` | A related object via a generic foreign key | -| `netbox.ui.attrs.GPSCoordinatesAttr` | GPS coordinates (latitude and longitude) | -| `netbox.ui.attrs.ImageAttr` | An attached image (displays the image) | -| `netbox.ui.attrs.NestedObjectAttr` | A related nested object (includes ancestors) | -| `netbox.ui.attrs.NumericAttr` | An integer or float value | -| `netbox.ui.attrs.RelatedObjectAttr` | A related object | -| `netbox.ui.attrs.RelatedObjectListAttr` | A list of related objects | -| `netbox.ui.attrs.TemplatedAttr` | Renders an attribute using a custom template | -| `netbox.ui.attrs.TextAttr` | A string (text) value | -| `netbox.ui.attrs.TimezoneAttr` | A timezone with annotated offset | -| `netbox.ui.attrs.UtilizationAttr` | A numeric value expressed as a utilization graph | +| Class | Description | +|-----------------------------------------|-----------------------------------------------------| +| `netbox.ui.attrs.AddressAttr` | A physical or mailing address. | +| `netbox.ui.attrs.ArrayAttr` | An array of values, shown as a comma-separated list | +| `netbox.ui.attrs.BooleanAttr` | A boolean value | +| `netbox.ui.attrs.ChoiceAttr` | A selection from a set of choices | +| `netbox.ui.attrs.ColorAttr` | A color expressed in RGB | +| `netbox.ui.attrs.DateTimeAttr` | A date or datetime value | +| `netbox.ui.attrs.GenericForeignKeyAttr` | A related object via a generic foreign key | +| `netbox.ui.attrs.GPSCoordinatesAttr` | GPS coordinates (latitude and longitude) | +| `netbox.ui.attrs.ImageAttr` | An attached image (displays the image) | +| `netbox.ui.attrs.NestedObjectAttr` | A related nested object (includes ancestors) | +| `netbox.ui.attrs.NumericAttr` | An integer or float value | +| `netbox.ui.attrs.RelatedObjectAttr` | A related object | +| `netbox.ui.attrs.RelatedObjectListAttr` | A list of related objects | +| `netbox.ui.attrs.TemplatedAttr` | Renders an attribute using a custom template | +| `netbox.ui.attrs.TextAttr` | A string (text) value | +| `netbox.ui.attrs.TimezoneAttr` | A timezone with annotated offset | +| `netbox.ui.attrs.UtilizationAttr` | A numeric value expressed as a utilization graph | ::: netbox.ui.attrs.ObjectAttribute ::: netbox.ui.attrs.AddressAttr +::: netbox.ui.attrs.ArrayAttr + ::: netbox.ui.attrs.BooleanAttr ::: netbox.ui.attrs.ChoiceAttr diff --git a/netbox/netbox/tests/test_ui.py b/netbox/netbox/tests/test_ui.py index 55cbb3842..9ecd91ef0 100644 --- a/netbox/netbox/tests/test_ui.py +++ b/netbox/netbox/tests/test_ui.py @@ -3,6 +3,7 @@ from types import SimpleNamespace from django.template import Context, Template from django.test import RequestFactory, SimpleTestCase, TestCase +from netaddr import IPNetwork from circuits.choices import CircuitStatusChoices, VirtualCircuitTerminationRoleChoices from circuits.models import ( @@ -250,6 +251,29 @@ class TextAttrTestCase(TestCase): self.assertTrue(context['copy_button']) +class ArrayAttrTestCase(TestCase): + + def test_get_value(self): + attr = attrs.ArrayAttr('allowed_ips') + obj = SimpleNamespace(allowed_ips=[IPNetwork('192.168.1.1/32'), IPNetwork('2001:db8::/64')]) + self.assertEqual(attr.get_value(obj), '192.168.1.1/32, 2001:db8::/64') + + def test_get_value_empty(self): + attr = attrs.ArrayAttr('allowed_ips') + obj = SimpleNamespace(allowed_ips=[]) + self.assertIsNone(attr.get_value(obj)) + + def test_get_value_none(self): + attr = attrs.ArrayAttr('allowed_ips') + obj = SimpleNamespace(allowed_ips=None) + self.assertIsNone(attr.get_value(obj)) + + def test_get_value_with_format_string(self): + attr = attrs.ArrayAttr('ports', format_string='{}/tcp') + obj = SimpleNamespace(ports=[80, 443]) + self.assertEqual(attr.get_value(obj), '80/tcp, 443/tcp') + + class NumericAttrTestCase(TestCase): def test_get_context_with_unit_accessor(self): diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 8734fb4a1..60a14d33a 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -8,6 +8,7 @@ from utilities.data import resolve_attr_path __all__ = ( 'AddressAttr', + 'ArrayAttr', 'BooleanAttr', 'ChoiceAttr', 'ColorAttr', @@ -141,6 +142,22 @@ class TextAttr(ObjectAttribute): } +class ArrayAttr(TextAttr): + """ + An attribute comprising an array of values, rendered as a comma-separated list. If specified, `format_string` + is applied to each item individually. Null and empty arrays are treated as equivalent: both render as the + placeholder. + """ + + def get_value(self, obj): + value = resolve_attr_path(obj, self.accessor) + if not value: + return None + if self.format_string: + return ', '.join(self.format_string.format(v) for v in value) + return ', '.join(str(v) for v in value) + + class NumericAttr(ObjectAttribute): """ An integer or float attribute.