Просмотр исходного кода

Closes #14736: Enable HTMX navigation globally (#15158)

* Enable HTMX boosting

* Refactor HTMX properties for tables

* Fix dashboard object list widget

* Disable scrolling to page content

* Fix initialization of TomSelect dropdowns after HTMX loading

* Replace formaction properties with hx-post

* Fix quick search field on object list view

* Reinitialize copy-to-clipboard buttons upon HTMX load

* Disable scrolling effect for intra-page navigation

* Introduce user preference for toggling HTMX navigation

* Enable HTMX navigation only when selected by user

* Pass htmx_navigation context

* Fix display of confirmation form when deleting an object

* Disable HTMX boosting for rack elevation SVG downloads

* Fix dyanmic form rendering

* Introduce htmx_boost template tag; enable HTMX for user menu

* Use out-of-band sap to update footer stamp

* Fix display of toasts after form submission

* Fix user preference selection

* Misc cleanup

* Rename render_partial() to htmx_partial()

* Add docstring to htmx_boost template tag

* Disable HTMX for user preferences form to force a full page refresh on changes
Jeremy Stretch 1 год назад
Родитель
Сommit
744be59a4d
54 измененных файлов с 206 добавлено и 153 удалено
  1. 4 3
      netbox/core/views.py
  2. 2 1
      netbox/extras/views.py
  3. 3 1
      netbox/netbox/context_processors.py
  4. 8 0
      netbox/netbox/preferences.py
  5. 3 2
      netbox/netbox/views/generic/bulk_views.py
  6. 4 3
      netbox/netbox/views/generic/object_views.py
  7. 2 1
      netbox/netbox/views/misc.py
  8. 0 0
      netbox/project-static/dist/netbox.css
  9. 0 0
      netbox/project-static/dist/netbox.js
  10. 0 0
      netbox/project-static/dist/netbox.js.map
  11. 4 14
      netbox/project-static/src/htmx.ts
  12. 1 0
      netbox/project-static/styles/netbox.scss
  13. 4 0
      netbox/project-static/styles/overrides/_bootstrap.scss
  14. 1 1
      netbox/templates/account/preferences.html
  15. 1 0
      netbox/templates/base/base.html
  16. 45 2
      netbox/templates/base/layout.html
  17. 1 1
      netbox/templates/dcim/component_list.html
  18. 2 2
      netbox/templates/dcim/device/components_base.html
  19. 1 1
      netbox/templates/dcim/device/consoleports.html
  20. 1 1
      netbox/templates/dcim/device/consoleserverports.html
  21. 1 1
      netbox/templates/dcim/device/frontports.html
  22. 1 1
      netbox/templates/dcim/device/interfaces.html
  23. 1 1
      netbox/templates/dcim/device/poweroutlets.html
  24. 1 1
      netbox/templates/dcim/device/powerports.html
  25. 1 1
      netbox/templates/dcim/device/rearports.html
  26. 11 11
      netbox/templates/dcim/device_list.html
  27. 2 2
      netbox/templates/dcim/devicetype/component_templates.html
  28. 1 1
      netbox/templates/dcim/inc/rack_elevation.html
  29. 3 3
      netbox/templates/dcim/moduletype/component_templates.html
  30. 3 3
      netbox/templates/dcim/powerpanel.html
  31. 1 1
      netbox/templates/extras/configcontext_list.html
  32. 1 1
      netbox/templates/extras/configtemplate_list.html
  33. 1 1
      netbox/templates/extras/dashboard/widgets/objectlist.html
  34. 1 1
      netbox/templates/extras/exporttemplate_list.html
  35. 2 2
      netbox/templates/generic/object_children.html
  36. 1 1
      netbox/templates/generic/object_edit.html
  37. 1 1
      netbox/templates/inc/messages.html
  38. 10 25
      netbox/templates/inc/paginator.html
  39. 1 1
      netbox/templates/inc/table_controls_htmx.html
  40. 5 5
      netbox/templates/inc/table_htmx.html
  41. 0 41
      netbox/templates/inc/user_menu.html
  42. 1 1
      netbox/templates/virtualization/cluster/devices.html
  43. 1 1
      netbox/templates/virtualization/virtualmachine/interfaces.html
  44. 1 1
      netbox/templates/virtualization/virtualmachine/virtual_disks.html
  45. 2 2
      netbox/templates/virtualization/virtualmachine_list.html
  46. 2 1
      netbox/users/forms/model_forms.py
  47. 13 0
      netbox/utilities/htmx.py
  48. 2 1
      netbox/utilities/templates/builtins/htmx_table.html
  49. 1 1
      netbox/utilities/templates/buttons/bulk_delete.html
  50. 1 1
      netbox/utilities/templates/buttons/bulk_edit.html
  51. 2 0
      netbox/utilities/templates/buttons/delete.html
  52. 2 1
      netbox/utilities/templates/navigation/menu.html
  53. 12 0
      netbox/utilities/templatetags/builtins/tags.py
  54. 30 5
      netbox/utilities/templatetags/navigation.py

+ 4 - 3
netbox/core/views.py

@@ -25,6 +25,7 @@ from netbox.views import generic
 from netbox.views.generic.base import BaseObjectView
 from netbox.views.generic.mixins import TableMixin
 from utilities.forms import ConfirmationForm
+from utilities.htmx import htmx_partial
 from utilities.query import count_related
 from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
 from . import filtersets, forms, tables
@@ -320,7 +321,7 @@ class BackgroundTaskListView(TableMixin, BaseRQView):
         table = self.get_table(data, request, False)
 
         # If this is an HTMX request, return only the rendered table HTML
-        if request.htmx:
+        if htmx_partial(request):
             return render(request, 'htmx/table.html', {
                 'table': table,
             })
@@ -489,8 +490,8 @@ class WorkerListView(TableMixin, BaseRQView):
         table = self.get_table(data, request, False)
 
         # If this is an HTMX request, return only the rendered table HTML
-        if request.htmx:
-            if request.htmx.target != 'object_list':
+        if htmx_partial(request):
+            if not request.htmx.target:
                 table.embedded = True
                 # Hide selection checkboxes
                 if 'pk' in table.base_columns:

+ 2 - 1
netbox/extras/views.py

@@ -20,6 +20,7 @@ from netbox.views import generic
 from netbox.views.generic.mixins import TableMixin
 from utilities.data import shallow_compare_dict
 from utilities.forms import ConfirmationForm, get_field_value
+from utilities.htmx import htmx_partial
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.query import count_related
 from utilities.querydict import normalize_querydict
@@ -1224,7 +1225,7 @@ class ScriptResultView(TableMixin, generic.ObjectView):
             }
 
         # If this is an HTMX request, return only the result HTML
-        if request.htmx:
+        if htmx_partial(request):
             response = render(request, 'extras/htmx/script_result.html', context)
             if job.completed or not job.started:
                 response.status_code = 286

+ 3 - 1
netbox/netbox/context_processors.py

@@ -8,9 +8,11 @@ def settings_and_registry(request):
     """
     Expose Django settings and NetBox registry stores in the template context. Example: {{ settings.DEBUG }}
     """
+    user_preferences = request.user.config if request.user.is_authenticated else {}
     return {
         'settings': django_settings,
         'config': get_config(),
         'registry': registry,
-        'preferences': request.user.config if request.user.is_authenticated else {},
+        'preferences': user_preferences,
+        'htmx_navigation': user_preferences.get('ui.htmx_navigation', False) == 'true'
     }

+ 8 - 0
netbox/netbox/preferences.py

@@ -23,6 +23,14 @@ PREFERENCES = {
         ),
         default='light',
     ),
+    'ui.htmx_navigation': UserPreference(
+        label=_('HTMX Navigation'),
+        choices=(
+            ('', _('Disabled')),
+            ('true', _('Enabled')),
+        ),
+        default=False
+    ),
     'locale.language': UserPreference(
         label=_('Language'),
         choices=(

+ 3 - 2
netbox/netbox/views/generic/bulk_views.py

@@ -23,6 +23,7 @@ from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
 from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
 from utilities.forms.bulk_import import BulkImportForm
+from utilities.htmx import htmx_partial
 from utilities.permissions import get_permission_for_model
 from utilities.views import GetReturnURLMixin, get_viewname
 from .base import BaseMultiObjectView
@@ -161,8 +162,8 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
         table = self.get_table(self.queryset, request, has_bulk_actions)
 
         # If this is an HTMX request, return only the rendered table HTML
-        if request.htmx:
-            if request.htmx.target != 'object_list':
+        if htmx_partial(request):
+            if not request.htmx.target:
                 table.embedded = True
                 # Hide selection checkboxes
                 if 'pk' in table.base_columns:

+ 4 - 3
netbox/netbox/views/generic/object_views.py

@@ -17,6 +17,7 @@ from extras.signals import clear_events
 from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import AbortRequest, PermissionsViolation
 from utilities.forms import ConfirmationForm, restrict_form_fields
+from utilities.htmx import htmx_partial
 from utilities.permissions import get_permission_for_model
 from utilities.querydict import normalize_querydict, prepare_cloned_fields
 from utilities.views import GetReturnURLMixin, get_viewname
@@ -138,7 +139,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
         table = self.get_table(table_data, request, has_bulk_actions)
 
         # If this is an HTMX request, return only the rendered table HTML
-        if request.htmx:
+        if htmx_partial(request):
             return render(request, 'htmx/table.html', {
                 'object': instance,
                 'table': table,
@@ -226,7 +227,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
         restrict_form_fields(form, request.user)
 
         # If this is an HTMX request, return only the rendered form HTML
-        if request.htmx:
+        if htmx_partial(request):
             return render(request, 'htmx/form.html', {
                 'form': form,
             })
@@ -482,7 +483,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
         instance = self.alter_object(self.queryset.model(), request)
 
         # If this is an HTMX request, return only the rendered form HTML
-        if request.htmx:
+        if htmx_partial(request):
             return render(request, 'htmx/form.html', {
                 'form': form,
             })

+ 2 - 1
netbox/netbox/views/misc.py

@@ -17,6 +17,7 @@ from netbox.forms import SearchForm
 from netbox.search import LookupTypes
 from netbox.search.backends import search_backend
 from netbox.tables import SearchTable
+from utilities.htmx import htmx_partial
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 
 __all__ = (
@@ -104,7 +105,7 @@ class SearchView(View):
         }).configure(table)
 
         # If this is an HTMX request, return only the rendered table HTML
-        if request.htmx:
+        if htmx_partial(request):
             return render(request, 'htmx/table.html', {
                 'table': table,
             })

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 4 - 14
netbox/project-static/src/htmx.ts

@@ -1,11 +1,12 @@
-import { getElements, isTruthy } from './util';
 import { initButtons } from './buttons';
+import { initClipboard } from './clipboard'
 import { initSelects } from './select';
 import { initObjectSelector } from './objectSelector';
 import { initBootstrap } from './bs';
+import { initMessages } from './messages';
 
 function initDepedencies(): void {
-  for (const init of [initButtons, initSelects, initObjectSelector, initBootstrap]) {
+  for (const init of [initButtons, initClipboard, initSelects, initObjectSelector, initBootstrap, initMessages]) {
     init();
   }
 }
@@ -15,16 +16,5 @@ function initDepedencies(): void {
  * elements.
  */
 export function initHtmx(): void {
-  for (const element of getElements('[hx-target]')) {
-    const targetSelector = element.getAttribute('hx-target');
-    if (isTruthy(targetSelector)) {
-      for (const target of getElements(targetSelector)) {
-        target.addEventListener('htmx:afterSettle', initDepedencies);
-      }
-    }
-  }
-
-  for (const element of getElements('[hx-trigger=load]')) {
-    element.addEventListener('htmx:afterSettle', initDepedencies);
-  }
+  document.addEventListener('htmx:afterSettle', initDepedencies);
 }

+ 1 - 0
netbox/project-static/styles/netbox.scss

@@ -5,6 +5,7 @@
 @import '../node_modules/@tabler/core/src/scss/vendor/tom-select';
 
 // Overrides of external libraries
+@import 'overrides/bootstrap';
 @import 'overrides/tabler';
 
 // Transitional styling to ease migration of templates from NetBox v3.x

+ 4 - 0
netbox/project-static/styles/overrides/_bootstrap.scss

@@ -0,0 +1,4 @@
+// Disable smooth scrolling for intra-page links
+html {
+    scroll-behavior: auto !important;
+}

+ 1 - 1
netbox/templates/account/preferences.html

@@ -6,7 +6,7 @@
 {% block title %}{% trans "User Preferences" %}{% endblock %}
 
 {% block content %}
-  <form method="post" action="" id="preferences-update">
+  <form method="post" action="" hx-disable="true" id="preferences-update">
     {% csrf_token %}
 
     {# Built-in preferences #}

+ 1 - 0
netbox/templates/base/base.html

@@ -15,6 +15,7 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width, viewport-fit=cover" />
+    <meta name="htmx-config" content='{"scrollBehavior": "auto"}'>
 
     {# Page title #}
     <title>{% block title %}{% trans "Home" %}{% endblock %} | NetBox</title>

+ 45 - 2
netbox/templates/base/layout.html

@@ -58,8 +58,48 @@ Blocks:
               <i class="mdi mdi-lightbulb-on"></i>
             </button>
           </div>
+
           {# User menu #}
-          {% include 'inc/user_menu.html' %}
+          {% if request.user.is_authenticated %}
+            <div class="nav-item dropdown">
+              <a href="#" class="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown" aria-label="Open user menu">
+                <div class="d-xl-block ps-2">
+                  <div>{{ request.user }}</div>
+                  <div class="mt-1 small text-secondary">{% if request.user.is_staff %}Staff{% else %}User{% endif %}</div>
+                </div>
+              </a>
+              <div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow" {% htmx_boost %}>
+                {% if config.DJANGO_ADMIN_ENABLED and request.user.is_staff %}
+                  <a class="dropdown-item" href="{% url 'admin:index' %}">
+                    <i class="mdi mdi-cog"></i> {% trans "Django Admin" %}
+                  </a>
+                {% endif %}
+                <a href="{% url 'account:profile' %}" class="dropdown-item">
+                  <i class="mdi mdi-account"></i> {% trans "Profile" %}
+                </a>
+                <a href="{% url 'account:bookmarks' %}" class="dropdown-item">
+                  <i class="mdi mdi-bookmark"></i> {% trans "Bookmarks" %}
+                </a>
+                <a href="{% url 'account:preferences' %}" class="dropdown-item">
+                  <i class="mdi mdi-wrench"></i> {% trans "Preferences" %}
+                </a>
+                <a href="{% url 'account:usertoken_list' %}" class="dropdown-item">
+                  <i class="mdi mdi-key"></i> {% trans "API Tokens" %}
+                </a>
+                <div class="dropdown-divider"></div>
+                <a href="{% url 'logout' %}" class="dropdown-item">
+                  <i class="mdi mdi-logout-variant"></i> {% trans "Log Out" %}
+                </a>
+              </div>
+            </div>
+          {% else %}
+            <div class="btn-group ps-2">
+              <a class="btn btn-primary" type="button" href="{% url 'login' %}?next={{ request.path }}">
+                <i class="mdi mdi-login-variant"></i> {% trans "Log In" %}
+              </a>
+            </div>
+          {% endif %}
+          {# /User menu #}
         </div>
 
         {# Search box #}
@@ -79,6 +119,7 @@ Blocks:
 
     {# Page content #}
     <div class="page-wrapper">
+      <div id="page-content" {% htmx_boost %}>
 
       {# Page header #}
       {% block header %}
@@ -122,6 +163,8 @@ Blocks:
       {% endif %}
       {# /Bottom banner #}
 
+      </div>
+
       {# Page footer #}
       <footer class="footer footer-transparent d-print-none py-2">
         <div class="container-fluid d-flex justify-content-between align-items-center">
@@ -173,7 +216,7 @@ Blocks:
             {# /Footer links #}
 
             {# Footer text #}
-            <ul class="list-inline list-inline-dots mb-0">
+            <ul class="list-inline list-inline-dots mb-0" id="footer-stamp" hx-swap-oob="true">
               <li class="list-inline-item">
                 {% annotated_now %} {% now 'T' %}
               </li>

+ 1 - 1
netbox/templates/dcim/component_list.html

@@ -10,7 +10,7 @@
     {% endif %}
     {% if 'bulk_rename' in actions %}
       {% with bulk_rename_view=model|validated_viewname:"bulk_rename" %}
-        <button type="submit" name="_rename" formaction="{% url bulk_rename_view %}" class="btn btn-outline-warning">
+        <button type="submit" name="_rename" {% formaction %}="{% url bulk_rename_view %}" class="btn btn-outline-warning">
           <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename Selected" %}
         </button>
       {% endwith %}

+ 2 - 2
netbox/templates/dcim/device/components_base.html

@@ -5,7 +5,7 @@
     {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
         {% if 'bulk_edit' in actions and bulk_edit_view %}
             <button type="submit" name="_edit"
-                    formaction="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
+                    {% formaction %}="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
                     class="btn btn-warning">
                 <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
             </button>
@@ -14,7 +14,7 @@
     {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
         {% if 'bulk_rename' in actions and bulk_rename_view %}
             <button type="submit" name="_rename"
-                    formaction="{% url bulk_rename_view %}?return_url={{ return_url }}"
+                    {% formaction %}="{% url bulk_rename_view %}?return_url={{ return_url }}"
                     class="btn btn-outline-warning">
                 <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
             </button>

+ 1 - 1
netbox/templates/dcim/device/consoleports.html

@@ -7,7 +7,7 @@
     {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
         {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
             <button type="submit" name="_disconnect"
-                    formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
+                    {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
                     class="btn btn-outline-danger">
                 <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
             </button>

+ 1 - 1
netbox/templates/dcim/device/consoleserverports.html

@@ -7,7 +7,7 @@
     {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
         {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
             <button type="submit" name="_disconnect"
-                    formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
+                    {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
                     class="btn btn-outline-danger">
                 <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
             </button>

+ 1 - 1
netbox/templates/dcim/device/frontports.html

@@ -7,7 +7,7 @@
     {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
         {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
             <button type="submit" name="_disconnect"
-                    formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
+                    {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
                     class="btn btn-outline-danger">
                 <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
             </button>

+ 1 - 1
netbox/templates/dcim/device/interfaces.html

@@ -11,7 +11,7 @@
     {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
         {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
             <button type="submit" name="_disconnect"
-                    formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
+                    {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
                     class="btn btn-outline-danger">
                 <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
             </button>

+ 1 - 1
netbox/templates/dcim/device/poweroutlets.html

@@ -7,7 +7,7 @@
     {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
         {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
             <button type="submit" name="_disconnect"
-                    formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
+                    {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
                     class="btn btn-outline-danger">
                 <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
             </button>

+ 1 - 1
netbox/templates/dcim/device/powerports.html

@@ -7,7 +7,7 @@
     {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
         {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
             <button type="submit" name="_disconnect"
-                    formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
+                    {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
                     class="btn btn-outline-danger">
                 <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
             </button>

+ 1 - 1
netbox/templates/dcim/device/rearports.html

@@ -7,7 +7,7 @@
     {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
         {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
             <button type="submit" name="_disconnect"
-                    formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
+                    {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
                     class="btn btn-outline-danger">
                 <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
             </button>

+ 11 - 11
netbox/templates/dcim/device_list.html

@@ -11,63 +11,63 @@
       <ul class="dropdown-menu">
         {% if perms.dcim.add_consoleport %}
           <li>
-            <button type="submit" formaction="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+            <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
              {% trans "Console Ports" %}
             </button>
           </li>
         {% endif %}
         {% if perms.dcim.add_consoleserverport %}
           <li>
-            <button type="submit" formaction="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item ">
+            <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item ">
               {% trans "Console Server Ports" %}
             </button>
           </li>
         {% endif %}
         {% if perms.dcim.add_powerport %}
           <li>
-            <button type="submit" formaction="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+            <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
               {% trans "Power Ports" %}
             </button>
           </li>
         {% endif %}
         {% if perms.dcim.add_poweroutlet %}
           <li>
-            <button type="submit" formaction="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+            <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
               {% trans "Power Outlets" %}
             </button>
           </li>
         {% endif %}
         {% if perms.dcim.add_interface %}
           <li>
-              <button type="submit" formaction="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}"
-                 class="dropdown-item">{% trans "Interfaces" %}
+              <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+                {% trans "Interfaces" %}
               </button>
           </li>
         {% endif %}
         {% if perms.dcim.add_rearport %}
           <li>
-            <button type="submit" formaction="{% url 'dcim:device_bulk_add_rearport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+            <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_rearport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
               {% trans "Rear Ports" %}
             </button>
           </li>
         {% endif %}
         {% if perms.dcim.add_devicebay %}
           <li>
-            <button type="submit" formaction="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+            <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
               {% trans "Device Bays" %}
             </button>
           </li>
         {% endif %}
         {% if perms.dcim.add_modulebay %}
           <li>
-            <button type="submit" formaction="{% url 'dcim:device_bulk_add_modulebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+            <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_modulebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
               {% trans "Module Bays" %}
             </button>
           </li>
         {% endif %}
         {% if perms.dcim.add_inventoryitem %}
           <li>
-            <button type="submit" formaction="{% url 'dcim:device_bulk_add_inventoryitem' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+            <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_inventoryitem' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
               {% trans "Inventory Items" %}
             </button>
           </li>
@@ -78,7 +78,7 @@
   {% if 'bulk_edit' in actions %}
     <div class="btn-group" role="group">
       {% bulk_edit_button model query_params=request.GET %}
-      <button type="submit" name="_rename" formaction="{% url 'dcim:device_bulk_rename' %}?return_url={% url 'dcim:device_list' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-outline-warning">
+      <button type="submit" name="_rename" {% formaction %}="{% url 'dcim:device_bulk_rename' %}?return_url={% url 'dcim:device_list' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-outline-warning">
         <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
       </button>
     </div>

+ 2 - 2
netbox/templates/dcim/devicetype/component_templates.html

@@ -7,7 +7,7 @@
     {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
         {% if 'bulk_edit' in actions and bulk_edit_view %}
             <button type="submit" name="_edit"
-                    formaction="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
+                    {% formaction %}="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
                     class="btn btn-warning">
                 <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
             </button>
@@ -16,7 +16,7 @@
     {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
         {% if 'bulk_rename' in actions and bulk_rename_view %}
             <button type="submit" name="_rename"
-                    formaction="{% url bulk_rename_view %}?return_url={{ return_url }}"
+                    {% formaction %}="{% url bulk_rename_view %}?return_url={{ return_url }}"
                     class="btn btn-outline-warning">
                 <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
             </button>

+ 1 - 1
netbox/templates/dcim/inc/rack_elevation.html

@@ -3,7 +3,7 @@
     <object data="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}" class="rack_elevation"></object>
 </div>
 <div class="text-center mt-3">
-    <a class="btn btn-outline-primary" href="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}">
+    <a class="btn btn-outline-primary" href="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}" hx-boost="false">
         <i class="mdi mdi-file-download"></i> {% trans "Download SVG" %}
     </a>
 </div>

+ 3 - 3
netbox/templates/dcim/moduletype/component_templates.html

@@ -13,13 +13,13 @@
             </div>
             <div class="card-footer d-print-none">
                 {% if table.rows %}
-                    <button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ return_url }}" class="btn btn-warning">
+                    <button type="submit" name="_edit" {% formaction %}="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ return_url }}" class="btn btn-warning">
                         <span class="mdi mdi-pencil-outline" aria-hidden="true"></span> {% trans "Rename" %}
                     </button>
-                    <button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ return_url }}" class="btn btn-warning">
+                    <button type="submit" name="_edit" {% formaction %}="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ return_url }}" class="btn btn-warning">
                         <span class="mdi mdi-pencil" aria-hidden="true"></span> {% trans "Edit" %}
                     </button>
-                    <button type="submit" name="_delete" formaction="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ return_url }}" class="btn btn-danger">
+                    <button type="submit" name="_delete" {% formaction %}="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ return_url }}" class="btn btn-danger">
                         <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
                     </button>
                 {% endif %}

+ 3 - 3
netbox/templates/dcim/powerpanel.html

@@ -52,17 +52,17 @@
         {% htmx_table 'dcim:powerfeed_list' power_panel_id=object.pk %}
         <div class="card-footer d-print-none">
           {% if perms.dcim.change_powerfeed %}
-            <button type="submit" name="_edit" formaction="{% url 'dcim:powerfeed_bulk_edit' %}?return_url={% url 'dcim:powerpanel' pk=object.pk %}" class="btn btn-warning">
+            <button type="submit" name="_edit" {% formaction %}="{% url 'dcim:powerfeed_bulk_edit' %}?return_url={% url 'dcim:powerpanel' pk=object.pk %}" class="btn btn-warning">
               <span class="mdi mdi-pencil" aria-hidden="true"></span> {% trans "Edit" %}
             </button>
           {% endif %}
           {% if perms.dcim.delete_cable %}
-            <button type="submit" name="_disconnect" formaction="{% url 'dcim:powerfeed_bulk_disconnect' %}?return_url={% url 'dcim:powerpanel' pk=object.pk %}" class="btn btn-outline-danger">
+            <button type="submit" name="_disconnect" {% formaction %}="{% url 'dcim:powerfeed_bulk_disconnect' %}?return_url={% url 'dcim:powerpanel' pk=object.pk %}" class="btn btn-outline-danger">
               <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
             </button>
           {% endif %}
           {% if perms.dcim.delete_powerfeed %}
-            <button type="submit" name="_delete" formaction="{% url 'dcim:powerfeed_bulk_delete' %}?return_url={% url 'dcim:powerpanel' pk=object.pk %}" class="btn btn-danger">
+            <button type="submit" name="_delete" {% formaction %}="{% url 'dcim:powerfeed_bulk_delete' %}?return_url={% url 'dcim:powerpanel' pk=object.pk %}" class="btn btn-danger">
               <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> {% trans "Delete" %}
             </button>
           {% endif %}

+ 1 - 1
netbox/templates/extras/configcontext_list.html

@@ -3,7 +3,7 @@
 
 {% block bulk_buttons %}
   {% if perms.extras.sync_configcontext %}
-    <button type="submit" name="_sync" formaction="{% url 'extras:configcontext_bulk_sync' %}" class="btn btn-primary">
+    <button type="submit" name="_sync" {% formaction %}="{% url 'extras:configcontext_bulk_sync' %}" class="btn btn-primary">
       <i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync Data" %}
     </button>
   {% endif %}

+ 1 - 1
netbox/templates/extras/configtemplate_list.html

@@ -3,7 +3,7 @@
 
 {% block bulk_buttons %}
   {% if perms.extras.sync_configtemplate %}
-    <button type="submit" name="_sync" formaction="{% url 'extras:configtemplate_bulk_sync' %}" class="btn btn-primary">
+    <button type="submit" name="_sync" {% formaction %}="{% url 'extras:configtemplate_bulk_sync' %}" class="btn btn-primary">
       <i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync Data" %}
     </button>
   {% endif %}

+ 1 - 1
netbox/templates/extras/dashboard/widgets/objectlist.html

@@ -1,6 +1,6 @@
 {% load i18n %}
 {% if htmx_url and has_permission %}
-  <div class="htmx-container" hx-get="{{ htmx_url }}" hx-trigger="load"></div>
+  <div class="htmx-container" hx-get="{{ htmx_url }}" hx-trigger="load" hx-target="this" hx-select="table" hx-swap="innerHTML"></div>
 {% elif htmx_url %}
   <div class="text-muted text-center">
     <i class="mdi mdi-lock-outline"></i> {% trans "No permission to view this content" %}.

+ 1 - 1
netbox/templates/extras/exporttemplate_list.html

@@ -3,7 +3,7 @@
 
 {% block bulk_buttons %}
   {% if perms.extras.sync_configcontext %}
-    <button type="submit" name="_sync" formaction="{% url 'extras:exporttemplate_bulk_sync' %}" class="btn btn-primary">
+    <button type="submit" name="_sync" {% formaction %}="{% url 'extras:exporttemplate_bulk_sync' %}" class="btn btn-primary">
       <i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync Data" %}
     </button>
   {% endif %}

+ 2 - 2
netbox/templates/generic/object_children.html

@@ -21,7 +21,7 @@
                         {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
                             {% if 'bulk_edit' in actions and bulk_edit_view %}
                                 <button type="submit" name="_edit"
-                                        formaction="{% url bulk_edit_view %}?return_url={{ return_url }}"
+                                        {% formaction %}="{% url bulk_edit_view %}?return_url={{ return_url }}"
                                         class="btn btn-warning">
                                     <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit Selected" %}
                                 </button>
@@ -35,7 +35,7 @@
                         {% with bulk_delete_view=child_model|validated_viewname:"bulk_delete" %}
                             {% if 'bulk_delete' in actions and bulk_delete_view %}
                                 <button type="submit"
-                                        formaction="{% url bulk_delete_view %}?return_url={{ return_url }}"
+                                        {% formaction %}="{% url bulk_delete_view %}?return_url={{ return_url }}"
                                         class="btn btn-danger">
                                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete Selected" %}
                                 </button>

+ 1 - 1
netbox/templates/generic/object_edit.html

@@ -56,7 +56,7 @@ Context:
     <form action="" method="post" enctype="multipart/form-data" class="object-edit mt-5">
       {% csrf_token %}
 
-      <div id="form_fields">
+      <div id="form_fields" hx-disinherit="hx-select hx-swap">
         {% block form %}
           {% include 'htmx/form.html' %}
         {% endblock form %}

+ 1 - 1
netbox/templates/inc/messages.html

@@ -1,6 +1,6 @@
 {% load helpers %}
 
-<div id="django-messages" class="toast-container position-fixed bottom-0 end-0 p-3">
+<div id="django-messages" class="toast-container position-fixed bottom-0 end-0 p-3" hx-swap-oob="true">
 
   {# Non-Field Form Errors #}
   {% if form and form.non_field_errors %}

+ 10 - 25
netbox/templates/inc/paginator.html

@@ -2,7 +2,12 @@
 {% load i18n %}
 
 {% if page %}
-  <div class="d-flex justify-content-between align-items-center border-top p-2">
+  <div
+      class="d-flex justify-content-between align-items-center border-top p-2"
+      hx-target="closest .htmx-container"
+      hx-disinherit="hx-select hx-swap"
+      {% if not table.embedded %}hx-push-url="true"{% endif %}
+  >
 
     {# Pages carousel #}
     {% if paginator.num_pages > 1 %}
@@ -13,12 +18,7 @@
           {% if page.has_previous %}
             <li class="page-item">
               {% if htmx %}
-                <a href="#"
-                  hx-get="{{ table.htmx_url }}{% querystring request page=page.previous_page_number %}"
-                  hx-target="closest .htmx-container"
-                  {% if not table.embedded %}hx-push-url="true"{% endif %}
-                  class="page-link"
-                >
+                <a href="#" hx-get="{{ table.htmx_url }}{% querystring request page=page.previous_page_number %}" class="page-link">
                   <i class="mdi mdi-chevron-left"></i>
                 </a>
               {% else %}
@@ -34,12 +34,7 @@
           {% for p in page.smart_pages %}
             <li class="page-item{% if page.number == p %} active" aria-current="page{% endif %}">
               {% if p and htmx %}
-                <a href="#"
-                  hx-get="{{ table.htmx_url }}{% querystring request page=p %}"
-                  hx-target="closest .htmx-container"
-                  {% if not table.embedded %}hx-push-url="true"{% endif %}
-                  class="page-link"
-                >
+                <a href="#" hx-get="{{ table.htmx_url }}{% querystring request page=p %}" class="page-link">
                   {{ p }}
                 </a>
               {% elif p %}
@@ -57,12 +52,7 @@
           {% if page.has_next %}
             <li class="page-item">
               {% if htmx %}
-                <a href="#"
-                  hx-get="{{ table.htmx_url }}{% querystring request page=page.next_page_number %}"
-                  hx-target="closest .htmx-container"
-                  {% if not table.embedded %}hx-push-url="true"{% endif %}
-                  class="page-link"
-                >
+                <a href="#" hx-get="{{ table.htmx_url }}{% querystring request page=page.next_page_number %}" class="page-link">
                   <i class="mdi mdi-chevron-right"></i>
                 </a>
               {% else %}
@@ -97,12 +87,7 @@
           <div class="dropdown-menu">
             {% for n in page.paginator.get_page_lengths %}
               {% if htmx %}
-                <a href="#"
-                  hx-get="{{ table.htmx_url }}{% querystring request per_page=n %}"
-                  hx-target="closest .htmx-container"
-                  {% if not table.embedded %}hx-push-url="true"{% endif %}
-                  class="dropdown-item"
-                >{{ n }}</a>
+                <a href="#" hx-get="{{ table.htmx_url }}{% querystring request per_page=n %}" class="dropdown-item">{{ n }}</a>
               {% else %}
                 <a href="{% querystring request per_page=n %}" class="dropdown-item">{{ n }}</a>
               {% endif %}

+ 1 - 1
netbox/templates/inc/table_controls_htmx.html

@@ -3,7 +3,7 @@
 
 <div class="row mb-3">
   <div class="col-auto d-print-none">
-    <div class="input-group input-group-flat me-2 quicksearch">
+    <div class="input-group input-group-flat me-2 quicksearch" hx-disinherit="hx-select hx-swap">
       <input type="search" results="5" name="q" id="quicksearch" class="form-control px-2 py-1" placeholder="Quick search"
         hx-get="{{ request.full_path }}" hx-target="#object_list" hx-trigger="keyup changed delay:500ms, search" />
       <span class="input-group-text py-1">

+ 5 - 5
netbox/templates/inc/table_htmx.html

@@ -1,7 +1,11 @@
 {% load django_tables2 %}
 <table{% if table.attrs %} {{ table.attrs.as_html }}{% endif %}>
   {% if table.show_header %}
-    <thead>
+    <thead
+        hx-target="closest .htmx-container"
+        hx-disinherit="hx-select hx-swap"
+        {% if not table.embedded %} hx-push-url="true"{% endif %}
+    >
       <tr>
         {% for column in table.columns %}
           {% if column.orderable %}
@@ -10,16 +14,12 @@
                 <div class="float-end">
                   <a href="#"
                      hx-get="{{ table.htmx_url }}{% querystring table.prefixed_order_by_field='' %}"
-                     hx-target="closest .htmx-container"
-                     {% if not table.embedded %}hx-push-url="true"{% endif %}
                      class="text-danger"
                   ><i class="mdi mdi-close"></i></a>
                 </div>
               {% endif %}
               <a href="#"
                  hx-get="{{ table.htmx_url }}{% querystring table.prefixed_order_by_field=column.order_by_alias.next %}"
-                 hx-target="closest .htmx-container"
-                 {% if not table.embedded %}hx-push-url="true"{% endif %}
               >{{ column.header }}</a>
             </th>
           {% else %}

+ 0 - 41
netbox/templates/inc/user_menu.html

@@ -1,41 +0,0 @@
-{% load i18n %}
-
-{% if request.user.is_authenticated %}
-  <div class="nav-item dropdown">
-    <a href="#" class="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown" aria-label="Open user menu">
-      <div class="d-xl-block ps-2">
-        <div>{{ request.user }}</div>
-        <div class="mt-1 small text-secondary">{% if request.user.is_staff %}Staff{% else %}User{% endif %}</div>
-      </div>
-    </a>
-    <div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
-      {% if config.DJANGO_ADMIN_ENABLED and request.user.is_staff %}
-        <a class="dropdown-item" href="{% url 'admin:index' %}">
-          <i class="mdi mdi-cog"></i> {% trans "Django Admin" %}
-        </a>
-      {% endif %}
-      <a href="{% url 'account:profile' %}" class="dropdown-item">
-        <i class="mdi mdi-account"></i> {% trans "Profile" %}
-      </a>
-      <a href="{% url 'account:bookmarks' %}" class="dropdown-item">
-        <i class="mdi mdi-bookmark"></i> {% trans "Bookmarks" %}
-      </a>
-      <a href="{% url 'account:preferences' %}" class="dropdown-item">
-        <i class="mdi mdi-wrench"></i> {% trans "Preferences" %}
-      </a>
-      <a href="{% url 'account:usertoken_list' %}" class="dropdown-item">
-        <i class="mdi mdi-key"></i> {% trans "API Tokens" %}
-      </a>
-      <div class="dropdown-divider"></div>
-      <a href="{% url 'logout' %}" class="dropdown-item">
-        <i class="mdi mdi-logout-variant"></i> {% trans "Log Out" %}
-      </a>
-    </div>
-  </div>
-{% else %}
-  <div class="btn-group ps-2">
-    <a class="btn btn-primary" type="button" href="{% url 'login' %}?next={{ request.path }}">
-      <i class="mdi mdi-login-variant"></i> {% trans "Log In" %}
-    </a>
-  </div>
-{% endif %}

+ 1 - 1
netbox/templates/virtualization/cluster/devices.html

@@ -5,7 +5,7 @@
     {{ block.super }}
     {% if 'bulk_remove_devices' in actions %}
         <button type="submit" name="_remove"
-                formaction="{% url 'virtualization:cluster_remove_devices' pk=object.pk %}?return_url={{ return_url }}"
+                {% formaction %}="{% url 'virtualization:cluster_remove_devices' pk=object.pk %}?return_url={{ return_url }}"
                 class="btn btn-danger">
             <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> {% trans "Remove Selected" %}
         </button>

+ 1 - 1
netbox/templates/virtualization/virtualmachine/interfaces.html

@@ -6,7 +6,7 @@
     {{ block.super }}
     {% if 'bulk_rename' in actions %}
         <button type="submit" name="_rename"
-                formaction="{% url 'virtualization:vminterface_bulk_rename' %}?return_url={{ return_url }}"
+                {% formaction %}="{% url 'virtualization:vminterface_bulk_rename' %}?return_url={{ return_url }}"
                 class="btn btn-outline-warning">
             <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
         </button>

+ 1 - 1
netbox/templates/virtualization/virtualmachine/virtual_disks.html

@@ -6,7 +6,7 @@
     {{ block.super }}
     {% if 'bulk_rename' in actions %}
         <button type="submit" name="_rename"
-                formaction="{% url 'virtualization:virtualdisk_bulk_rename' %}?return_url={{ return_url }}"
+                {% formaction %}="{% url 'virtualization:virtualdisk_bulk_rename' %}?return_url={{ return_url }}"
                 class="btn btn-outline-warning">
             <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
         </button>

+ 2 - 2
netbox/templates/virtualization/virtualmachine_list.html

@@ -10,14 +10,14 @@
       <ul class="dropdown-menu">
         {% if perms.virtualization.add_vminterface %}
           <li>
-            <button type="submit" formaction="{% url 'virtualization:virtualmachine_bulk_add_vminterface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+            <button type="submit" {% formaction %}="{% url 'virtualization:virtualmachine_bulk_add_vminterface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
               {% trans "Interfaces" %}
             </button>
           </li>
         {% endif %}
         {% if perms.virtualization.add_virtualdisk %}
           <li>
-            <button type="submit" formaction="{% url 'virtualization:virtualmachine_bulk_add_virtualdisk' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+            <button type="submit" {% formaction %}="{% url 'virtualization:virtualmachine_bulk_add_virtualdisk' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
               {% trans "Virtual Disks" %}
             </button>
           </li>

+ 2 - 1
netbox/users/forms/model_forms.py

@@ -55,7 +55,8 @@ class UserConfigFormMetaclass(forms.models.ModelFormMetaclass):
 class UserConfigForm(forms.ModelForm, metaclass=UserConfigFormMetaclass):
     fieldsets = (
         FieldSet(
-            'locale.language', 'pagination.per_page', 'pagination.placement', 'ui.colormode', name=_('User Interface')
+            'locale.language', 'pagination.per_page', 'pagination.placement', 'ui.colormode', 'ui.htmx_navigation',
+            name=_('User Interface')
         ),
         FieldSet('data_format', name=_('Miscellaneous')),
     )

+ 13 - 0
netbox/utilities/htmx.py

@@ -0,0 +1,13 @@
+__all__ = (
+    'htmx_partial',
+)
+
+PAGE_CONTAINER_ID = 'page-content'
+
+
+def htmx_partial(request):
+    """
+    Determines whether to render partial (versus complete) HTML content
+    in response to an HTMX request, based on the target element.
+    """
+    return request.htmx and request.htmx.target != PAGE_CONTAINER_ID

+ 2 - 1
netbox/utilities/templates/builtins/htmx_table.html

@@ -1,4 +1,5 @@
 <div class="card-body htmx-container table-responsive p-0"
   hx-get="{% url viewname %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}"
-  hx-trigger="load"
+  hx-target="this"
+  hx-trigger="load" hx-select="table" hx-swap="innerHTML"
 ></div>

+ 1 - 1
netbox/utilities/templates/buttons/bulk_delete.html

@@ -1,6 +1,6 @@
 {% load i18n %}
 {% if url %}
-  <button type="submit" name="_delete" formaction="{{ url }}" class="btn btn-red">
+  <button type="submit" name="_delete" {% formaction %}="{{ url }}" class="btn btn-red">
     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete Selected" %}
   </button>
 {% endif %}

+ 1 - 1
netbox/utilities/templates/buttons/bulk_edit.html

@@ -1,6 +1,6 @@
 {% load i18n %}
 {% if url %}
-  <button type="submit" name="_edit" formaction="{{ url }}" class="btn btn-yellow">
+  <button type="submit" name="_edit" {% formaction %}="{{ url }}" class="btn btn-yellow">
     <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit Selected" %}
   </button>
 {% endif %}

+ 2 - 0
netbox/utilities/templates/buttons/delete.html

@@ -2,6 +2,8 @@
 <a href="#"
   hx-get="{{ url }}"
   hx-target="#htmx-modal-content"
+  hx-swap="innerHTML"
+  hx-select="form"
   class="btn btn-red"
   data-bs-toggle="modal"
   data-bs-target="#htmx-modal"

+ 2 - 1
netbox/utilities/templates/navigation/menu.html

@@ -1,6 +1,7 @@
 {% load helpers %}
+{% load navigation %}
 
-<ul class="navbar-nav pt-lg-2">
+<ul class="navbar-nav pt-lg-2" {% htmx_boost %}>
   {% for menu, groups in nav_items %}
     <li class="nav-item dropdown">
 

+ 12 - 0
netbox/utilities/templatetags/builtins/tags.py

@@ -8,6 +8,7 @@ __all__ = (
     'checkmark',
     'copy_content',
     'customfield_value',
+    'formaction',
     'tag',
 )
 
@@ -113,3 +114,14 @@ def htmx_table(context, viewname, return_url=None, **kwargs):
         'viewname': viewname,
         'url_params': url_params,
     }
+
+
+@register.simple_tag(takes_context=True)
+def formaction(context):
+    """
+    Replace the 'formaction' attribute on an HTML element with the appropriate HTMX attributes
+    if HTMX navigation is enabled (per the user's preferences).
+    """
+    if context.get('htmx_navigation', False):
+        return 'hx-push-url="true" hx-post'
+    return 'formaction'

+ 30 - 5
netbox/utilities/templatetags/navigation.py

@@ -1,11 +1,11 @@
-from typing import Dict
 from django import template
-from django.template import Context
+from django.utils.safestring import mark_safe
 
 from netbox.navigation.menu import MENUS
 
 __all__ = (
     'nav',
+    'htmx_boost',
 )
 
 
@@ -13,7 +13,7 @@ register = template.Library()
 
 
 @register.inclusion_tag("navigation/menu.html", takes_context=True)
-def nav(context: Context) -> Dict:
+def nav(context):
     """
     Render the navigation menu.
     """
@@ -40,6 +40,31 @@ def nav(context: Context) -> Dict:
             nav_items.append((menu, groups))
 
     return {
-        "nav_items": nav_items,
-        "request": context["request"]
+        'nav_items': nav_items,
+        'htmx_navigation': context['htmx_navigation']
     }
+
+
+@register.simple_tag(takes_context=True)
+def htmx_boost(context, target='#page-content', select='#page-content'):
+    """
+    Renders the HTML attributes needed to effect HTMX boosting within an element if
+    HTMX navigation is enabled for the request. The target and select parameters are
+    rendered as `hx-target` and `hx-select`, respectively. For example:
+
+        <div id="page-content" {% htmx_boost %}>
+
+    If HTMX navigation is not enabled, the tag renders no content.
+    """
+    if not context.get('htmx_navigation', False):
+        return ''
+    hx_params = {
+        'hx-boost': 'true',
+        'hx-target': target,
+        'hx-select': select,
+        'hx-swap': 'outerHTML show:window:top',
+    }
+    htmx_params = ' '.join([
+        f'{k}="{v}"' for k, v in hx_params.items()
+    ])
+    return mark_safe(htmx_params)

Некоторые файлы не были показаны из-за большого количества измененных файлов