Преглед изворни кода

fix(ui): Hide bulk action buttons when no actions are available

Check if bulk actions exist before rendering the sticky action card.
Prevents empty button containers from appearing when no bulk operations
are permitted for the current user or context.

Fixes #23240
Martin Hauser пре 1 дан
родитељ
комит
f95c567b59

+ 90 - 1
netbox/netbox/tests/test_views.py

@@ -5,7 +5,7 @@ from unittest.mock import patch
 from django.contrib.contenttypes.models import ContentType
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.http import HttpResponse
-from django.test import Client, TransactionTestCase, override_settings
+from django.test import Client, TransactionTestCase, override_settings, tag
 from django.urls import reverse
 from django.utils import timezone
 
@@ -17,6 +17,7 @@ from extras.models import ImageAttachment
 from extras.validators import CustomValidator
 from ipam.choices import VLANStatusChoices
 from ipam.models import VLAN, VLANGroup
+from ipam.views import VLANGroupVLANsView
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices
 from netbox.constants import EMPTY_TABLE_TEXT
 from netbox.search.backends import search_backend
@@ -130,6 +131,94 @@ class BulkImportViewTabsTestCase(TestCase):
         self.assertIn('<div class="tab-pane show active" id="import-form"', content)
 
 
+class BulkActionButtonsTestCase(TestCase):
+    """
+    Verify when list and child object views render their bulk action buttons.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.vlan_group = VLANGroup.objects.create(name='VLAN Group 1', slug='vlan-group-1')
+
+    @tag('regression')  # Ref: #23240
+    def test_object_list_without_bulk_actions(self):
+        """A list view without bulk actions renders no bulk action container."""
+        self.add_permissions('core.view_objectchange')
+
+        response = self.client.get(reverse('core:objectchange_list'))
+
+        self.assertHttpStatus(response, 200)
+        content = response.content.decode()
+        self.assertNotIn('data-sticky-when="selection"', content)
+
+    def test_object_list_with_bulk_actions(self):
+        """A list view with a permitted bulk action renders the bulk action container and its button."""
+        self.add_permissions('dcim.view_site', 'dcim.change_site')
+
+        response = self.client.get(reverse('dcim:site_list'))
+
+        self.assertHttpStatus(response, 200)
+        content = response.content.decode()
+        self.assertIn('data-sticky-when="selection"', content)
+        self.assertIn('name="_edit"', content)
+
+    @tag('regression')  # Ref: #23240
+    def test_object_children_without_bulk_actions(self):
+        """A child object view without permitted bulk actions renders no bulk action container."""
+        self.add_permissions('ipam.view_vlangroup', 'ipam.add_vlan')
+
+        response = self.client.get(reverse('ipam:vlangroup_vlans', kwargs={'pk': self.vlan_group.pk}))
+
+        self.assertHttpStatus(response, 200)
+        content = response.content.decode()
+        self.assertNotIn('data-sticky-when="selection"', content)
+
+    def test_object_children_with_bulk_actions(self):
+        """A child object view with a permitted bulk action renders the bulk action container and its button."""
+        self.add_permissions('ipam.view_vlangroup', 'ipam.change_vlan')
+
+        response = self.client.get(reverse('ipam:vlangroup_vlans', kwargs={'pk': self.vlan_group.pk}))
+
+        self.assertHttpStatus(response, 200)
+        content = response.content.decode()
+        self.assertIn('data-sticky-when="selection"', content)
+        self.assertIn('name="_edit"', content)
+
+    @tag('regression')  # Ref: #23240
+    def test_object_children_without_child_model(self):
+        """A child object view without a child model renders no bulk action container."""
+        self.add_permissions('ipam.view_vlangroup', 'ipam.change_vlangroup')
+
+        with patch.object(VLANGroupVLANsView, 'child_model', None):
+            response = self.client.get(reverse('ipam:vlangroup_vlans', kwargs={'pk': self.vlan_group.pk}))
+
+        self.assertHttpStatus(response, 200)
+        content = response.content.decode()
+        self.assertNotIn('data-sticky-when="selection"', content)
+
+    @tag('regression')  # Ref: #23240
+    def test_htmx_table_without_bulk_actions(self):
+        """An HTMX table response without bulk actions carries no bulk action button update."""
+        self.add_permissions('core.view_objectchange')
+
+        response = self.client.get(reverse('core:objectchange_list'), headers={'HX-Request': 'true'})
+
+        self.assertHttpStatus(response, 200)
+        content = response.content.decode()
+        self.assertNotIn('hx-swap-oob="outerHTML:.bulk-action-buttons"', content)
+
+    def test_htmx_table_with_bulk_actions(self):
+        """An HTMX table response with a permitted bulk action carries the bulk action button update."""
+        self.add_permissions('dcim.view_site', 'dcim.change_site')
+
+        response = self.client.get(reverse('dcim:site_list'), headers={'HX-Request': 'true'})
+
+        self.assertHttpStatus(response, 200)
+        content = response.content.decode()
+        self.assertIn('hx-swap-oob="outerHTML:.bulk-action-buttons"', content)
+        self.assertIn('name="_edit"', content)
+
+
 class SearchViewTestCase(TestCase):
 
     @classmethod

+ 8 - 3
netbox/templates/generic/object_children.html

@@ -33,14 +33,19 @@ Context:
                 {% include 'htmx/table.html' %}
             </div>
         </div>
-        <div class="card btn-list sticky-actions d-print-none" data-sticky-position="right" data-sticky-when="selection">
+        {% if model %}
+          {% action_buttons actions model multi=True return_url=request.path as bulk_actions %}
+        {% endif %}
+        {% if bulk_actions %}
+          <div class="card btn-list sticky-actions d-print-none" data-sticky-position="right" data-sticky-when="selection">
             {% block bulk_controls %}
               <div class="btn-list bulk-action-buttons">
-                {% action_buttons actions model multi=True return_url=request.path %}
+                {{ bulk_actions }}
               </div>
               {% block bulk_extra_controls %}{% endblock %}
             {% endblock bulk_controls %}
-        </div>
+          </div>
+        {% endif %}
     </form>
 {% endblock content %}
 

+ 10 - 7
netbox/templates/generic/object_list.html

@@ -107,13 +107,16 @@ Context:
           {# /Objects table #}
 
           {# Form buttons #}
-          <div class="card btn-list sticky-actions d-print-none" data-sticky-position="right" data-sticky-when="selection">
-            {% block bulk_buttons %}
-              <div class="btn-list bulk-action-buttons">
-                {% action_buttons actions model multi=True %}
-              </div>
-            {% endblock %}
-          </div>
+          {% action_buttons actions model multi=True as bulk_actions %}
+          {% if bulk_actions %}
+            <div class="card btn-list sticky-actions d-print-none" data-sticky-position="right" data-sticky-when="selection">
+              {% block bulk_buttons %}
+                <div class="btn-list bulk-action-buttons">
+                  {{ bulk_actions }}
+                </div>
+              {% endblock %}
+            </div>
+          {% endif %}
           {# /Form buttons #}
 
         </div>

+ 6 - 3
netbox/templates/htmx/table.html

@@ -40,9 +40,12 @@
 
   {# Update the bulk action buttons with new query parameters #}
   {% if actions and not table.embedded %}
-    <div class="btn-list bulk-action-buttons" hx-swap-oob="outerHTML:.bulk-action-buttons">
-      {% action_buttons actions model multi=True %}
-    </div>
+    {% action_buttons actions model multi=True as bulk_actions %}
+    {% if bulk_actions %}
+      <div class="btn-list bulk-action-buttons" hx-swap-oob="outerHTML:.bulk-action-buttons">
+        {{ bulk_actions }}
+      </div>
+    {% endif %}
   {% endif %}
 
   {# Update the return_url to reflect any changed query parameters (e.g. per_page) #}