diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py
index 44c3bb40a..06f28b4e6 100644
--- a/netbox/dcim/forms/model_forms.py
+++ b/netbox/dcim/forms/model_forms.py
@@ -16,7 +16,7 @@ from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField,
NumericArrayField, SlugField,
)
-from utilities.forms.rendering import InlineFields
+from utilities.forms.rendering import InlineFields, TabbedFieldGroups
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
from virtualization.models import Cluster
from wireless.models import WirelessLAN, WirelessLANGroup
@@ -237,8 +237,8 @@ class RackForm(TenancyForm, NetBoxModelForm):
'width',
'starting_unit',
'u_height',
- InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
- InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
+ InlineFields(_('Outer Dimensions'), 'outer_width', 'outer_depth', 'outer_unit'),
+ InlineFields(_('Weight'), 'weight', 'max_weight', 'weight_unit'),
'mounting_depth',
'desc_units',
)),
@@ -1414,6 +1414,17 @@ class InventoryItemForm(DeviceComponentForm):
fieldsets = (
(_('Inventory Item'), ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')),
(_('Hardware'), ('manufacturer', 'part_id', 'serial', 'asset_tag')),
+ (_('Component Assignment'), (
+ TabbedFieldGroups(
+ (_('Interface'), 'interface'),
+ (_('Console Port'), 'consoleport'),
+ (_('Console Server Port'), 'consoleserverport'),
+ (_('Front Port'), 'frontport'),
+ (_('Rear Port'), 'rearport'),
+ (_('Power Port'), 'powerport'),
+ (_('Power Outlet'), 'poweroutlet'),
+ ),
+ ))
)
class Meta:
@@ -1429,22 +1440,17 @@ class InventoryItemForm(DeviceComponentForm):
component_type = initial.get('component_type')
component_id = initial.get('component_id')
- # Used for picking the default active tab for component selection
- self.no_component = True
-
if instance:
- # When editing set the initial value for component selectin
+ # When editing set the initial value for component selection
for component_model in ContentType.objects.filter(MODULAR_COMPONENT_MODELS):
if type(instance.component) is component_model.model_class():
initial[component_model.model] = instance.component
- self.no_component = False
break
elif component_type and component_id:
# When adding the InventoryItem from a component page
if content_type := ContentType.objects.filter(MODULAR_COMPONENT_MODELS).filter(pk=component_type).first():
if component := content_type.model_class().objects.filter(pk=component_id).first():
initial[content_type.model] = component
- self.no_component = False
kwargs['initial'] = initial
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index b447ae579..49bbe9be1 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -2924,14 +2924,12 @@ class InventoryItemView(generic.ObjectView):
class InventoryItemEditView(generic.ObjectEditView):
queryset = InventoryItem.objects.all()
form = forms.InventoryItemForm
- template_name = 'dcim/inventoryitem_edit.html'
class InventoryItemCreateView(generic.ComponentCreateView):
queryset = InventoryItem.objects.all()
form = forms.InventoryItemCreateForm
model_form = forms.InventoryItemForm
- template_name = 'dcim/inventoryitem_edit.html'
@register_model_view(InventoryItem, 'delete')
diff --git a/netbox/templates/dcim/inventoryitem_edit.html b/netbox/templates/dcim/inventoryitem_edit.html
deleted file mode 100644
index 1dc46ddce..000000000
--- a/netbox/templates/dcim/inventoryitem_edit.html
+++ /dev/null
@@ -1,107 +0,0 @@
-{% extends 'generic/object_edit.html' %}
-{% load static %}
-{% load form_helpers %}
-{% load helpers %}
-{% load i18n %}
-
-{% block form %}
-
-
-
{% trans "Inventory Item" %}
-
- {% render_field form.device %}
- {% render_field form.parent %}
- {% render_field form.name %}
- {% render_field form.label %}
- {% render_field form.role %}
- {% render_field form.description %}
- {% render_field form.tags %}
-
-
-
-
-
{% trans "Hardware" %}
-
- {% render_field form.manufacturer %}
- {% render_field form.part_id %}
- {% render_field form.serial %}
- {% render_field form.asset_tag %}
-
-
-
-
-
{% trans "Component Assignment" %}
-
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
-
-
-
-
- {% render_field form.consoleport %}
-
-
- {% render_field form.consoleserverport %}
-
-
- {% render_field form.frontport %}
-
-
- {% render_field form.interface %}
-
-
- {% render_field form.poweroutlet %}
-
-
- {% render_field form.powerport %}
-
-
- {% render_field form.rearport %}
-
-
-
-
- {% if form.custom_fields %}
-
-
-
{% trans "Custom Fields" %}
-
- {% render_custom_fields form %}
-
- {% endif %}
-{% endblock %}
diff --git a/netbox/utilities/forms/rendering.py b/netbox/utilities/forms/rendering.py
index 498b1a2ce..ad87930a9 100644
--- a/netbox/utilities/forms/rendering.py
+++ b/netbox/utilities/forms/rendering.py
@@ -1,10 +1,43 @@
+import random
+import string
+from functools import cached_property
+
__all__ = (
+ 'FieldGroup',
'InlineFields',
+ 'TabbedFieldGroups',
)
-class InlineFields:
+class FieldGroup:
- def __init__(self, *field_names, label=None):
+ def __init__(self, label, *field_names):
self.field_names = field_names
self.label = label
+
+
+class InlineFields(FieldGroup):
+ pass
+
+
+class TabbedFieldGroups:
+
+ def __init__(self, *groups):
+ self.groups = [
+ FieldGroup(*group) for group in groups
+ ]
+
+ # Initialize a random ID for the group (for tab selection)
+ self.id = ''.join(
+ random.choice(string.ascii_lowercase + string.digits) for _ in range(8)
+ )
+
+ @cached_property
+ def tabs(self):
+ return [
+ {
+ 'id': f'{self.id}_{i}',
+ 'title': group.label,
+ 'fields': group.field_names,
+ } for i, group in enumerate(self.groups, start=1)
+ ]
diff --git a/netbox/utilities/templates/form_helpers/render_fieldset.html b/netbox/utilities/templates/form_helpers/render_fieldset.html
index 718a8f6a0..ee1f50293 100644
--- a/netbox/utilities/templates/form_helpers/render_fieldset.html
+++ b/netbox/utilities/templates/form_helpers/render_fieldset.html
@@ -7,9 +7,11 @@
{% endif %}
{% for layout, title, items in rows %}
+
{% if layout == 'field' %}
{# Single form field #}
{% render_field items.0 %}
+
{% elif layout == 'inline' %}
{# Multiple form fields on the same line #}
@@ -21,6 +23,30 @@
{% endfor %}
+
+ {% elif layout == 'tabs' %}
+ {# Tabbed groups of fields #}
+
+
+ {% for tab in items %}
+ -
+
+
+ {% endfor %}
+
+
+
+ {% for tab in items %}
+
+ {% for field in tab.fields %}
+ {% render_field field %}
+ {% endfor %}
+
+ {% endfor %}
+
+
{% endif %}
{% endfor %}
diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py
index 3f60627b4..47bbaafe8 100644
--- a/netbox/utilities/templatetags/form_helpers.py
+++ b/netbox/utilities/templatetags/form_helpers.py
@@ -1,6 +1,6 @@
from django import template
-from utilities.forms.rendering import InlineFields
+from utilities.forms.rendering import InlineFields, TabbedFieldGroups
__all__ = (
'getfield',
@@ -58,6 +58,21 @@ def render_fieldset(form, fieldset, heading=None):
rows.append(
('inline', item.label, [form[name] for name in item.field_names])
)
+ elif type(item) is TabbedFieldGroups:
+ tabs = [
+ {
+ 'id': tab['id'],
+ 'title': tab['title'],
+ 'active': bool(form.initial.get(tab['fields'][0], False)),
+ 'fields': [form[name] for name in tab['fields']]
+ } for tab in item.tabs
+ ]
+ # If none of the tabs has been marked as active, activate the first one
+ if not any(tab['active'] for tab in tabs):
+ tabs[0]['active'] = True
+ rows.append(
+ ('tabs', None, tabs)
+ )
else:
rows.append(
('field', None, [form[item]])