Compare commits

...

27 Commits

Author SHA1 Message Date
Jeremy Stretch 38bc314713
Merge c7a63e02f2 into 04d8db7c52 2024-03-27 16:12:03 +00:00
Jeremy Stretch c7a63e02f2 Disable HTMX for user preferences form to force a full page refresh on changes 2024-03-27 12:11:24 -04:00
Jeremy Stretch 41f21d31ef Add docstring to htmx_boost template tag 2024-03-27 09:30:11 -04:00
Jeremy Stretch 6c540e56ff Rename render_partial() to htmx_partial() 2024-03-27 09:24:39 -04:00
Jeremy Stretch 5fcc0fc2a4 Misc cleanup 2024-03-25 16:25:08 -04:00
Jeremy Stretch ec364c6fc2 Fix user preference selection 2024-03-25 16:04:06 -04:00
Jeremy Stretch 4c92397d71 Fix display of toasts after form submission 2024-03-25 15:12:41 -04:00
Jeremy Stretch 1af5799f2d Use out-of-band sap to update footer stamp 2024-03-25 14:39:26 -04:00
Jeremy Stretch e37c943d2b Introduce htmx_boost template tag; enable HTMX for user menu 2024-03-25 13:41:42 -04:00
Jeremy Stretch 16ceaef601 Fix dyanmic form rendering 2024-03-25 12:24:12 -04:00
Jeremy Stretch ebab17d851 Disable HTMX boosting for rack elevation SVG downloads 2024-03-25 12:09:14 -04:00
Jeremy Stretch a36467ce3c Fix display of confirmation form when deleting an object 2024-03-25 11:52:41 -04:00
Jeremy Stretch 3061e16304 Merge branch 'feature' into 14736-htmx 2024-03-25 10:31:05 -04:00
Jeremy Stretch 8ba5963ada Pass htmx_navigation context 2024-03-18 17:13:02 -04:00
Jeremy Stretch 4d85e9ce34 Enable HTMX navigation only when selected by user 2024-03-18 17:03:49 -04:00
Jeremy Stretch 7d86d0989d Introduce user preference for toggling HTMX navigation 2024-03-18 17:02:17 -04:00
Jeremy Stretch e735be7167 Merge branch 'feature' into 14736-htmx 2024-03-18 16:00:34 -04:00
Jeremy Stretch 236c561677 Disable scrolling effect for intra-page navigation 2024-02-29 12:46:34 -05:00
Jeremy Stretch c377d8360c Merge branch 'feature' into 14736-htmx 2024-02-29 11:39:05 -05:00
Jeremy Stretch 648a14ad98 Reinitialize copy-to-clipboard buttons upon HTMX load 2024-02-16 14:29:17 -05:00
Jeremy Stretch d224f734d5 Fix quick search field on object list view 2024-02-16 14:24:37 -05:00
Jeremy Stretch 881b994038 Replace formaction properties with hx-post 2024-02-16 14:17:17 -05:00
Jeremy Stretch a78fb8033c Fix initialization of TomSelect dropdowns after HTMX loading 2024-02-14 16:43:22 -05:00
Jeremy Stretch fa4f4893a3 Disable scrolling to page content 2024-02-14 15:46:43 -05:00
Jeremy Stretch 4f3f2142f4 Fix dashboard object list widget 2024-02-14 15:39:47 -05:00
Jeremy Stretch a742f2ebfd Refactor HTMX properties for tables 2024-02-14 15:05:33 -05:00
Jeremy Stretch 6d06152573 Enable HTMX boosting 2024-02-14 15:05:32 -05:00
54 changed files with 213 additions and 160 deletions

View File

@ -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:

View File

@ -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

View File

@ -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'
}

View File

@ -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=(

View File

@ -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:

View File

@ -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,
})

View File

@ -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,
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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);
}

View File

@ -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

View File

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

View File

@ -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 #}

View File

@ -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>

View File

@ -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>

View File

@ -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 %}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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" %}.

View File

@ -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 %}

View File

@ -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>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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">

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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
netbox/utilities/htmx.py Normal file
View File

@ -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

View File

@ -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>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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"

View File

@ -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">

View File

@ -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'

View File

@ -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)