diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index 10cd06563..9eeb0f588 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -50,7 +50,9 @@ class ProviderForm(NetBoxModelForm): class ProviderAccountForm(NetBoxModelForm): provider = DynamicModelChoiceField( label=_('Provider'), - queryset=Provider.objects.all() + queryset=Provider.objects.all(), + selector=True, + quick_add=True ) comments = CommentField() @@ -64,7 +66,9 @@ class ProviderAccountForm(NetBoxModelForm): class ProviderNetworkForm(NetBoxModelForm): provider = DynamicModelChoiceField( label=_('Provider'), - queryset=Provider.objects.all() + queryset=Provider.objects.all(), + selector=True, + quick_add=True ) comments = CommentField() @@ -97,7 +101,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm): provider = DynamicModelChoiceField( label=_('Provider'), queryset=Provider.objects.all(), - selector=True + selector=True, + quick_add=True ) provider_account = DynamicModelChoiceField( label=_('Provider account'), @@ -108,7 +113,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm): } ) type = DynamicModelChoiceField( - queryset=CircuitType.objects.all() + queryset=CircuitType.objects.all(), + quick_add=True ) comments = CommentField() diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 2fcdbe5fd..b004798af 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -112,12 +112,14 @@ class SiteForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( label=_('Region'), queryset=Region.objects.all(), - required=False + required=False, + quick_add=True ) group = DynamicModelChoiceField( label=_('Group'), queryset=SiteGroup.objects.all(), - required=False + required=False, + quick_add=True ) asns = DynamicModelMultipleChoiceField( queryset=ASN.objects.all(), @@ -206,7 +208,8 @@ class RackRoleForm(NetBoxModelForm): class RackTypeForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), - queryset=Manufacturer.objects.all() + queryset=Manufacturer.objects.all(), + quick_add=True ) comments = CommentField() slug = SlugField( @@ -348,7 +351,8 @@ class ManufacturerForm(NetBoxModelForm): class DeviceTypeForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), - queryset=Manufacturer.objects.all() + queryset=Manufacturer.objects.all(), + quick_add=True ) default_platform = DynamicModelChoiceField( label=_('Default platform'), @@ -436,7 +440,8 @@ class PlatformForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), queryset=Manufacturer.objects.all(), - required=False + required=False, + quick_add=True ) config_template = DynamicModelChoiceField( label=_('Config template'), @@ -508,7 +513,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm): ) role = DynamicModelChoiceField( label=_('Device role'), - queryset=DeviceRole.objects.all() + queryset=DeviceRole.objects.all(), + quick_add=True ) platform = DynamicModelChoiceField( label=_('Platform'), @@ -750,7 +756,8 @@ class PowerFeedForm(TenancyForm, NetBoxModelForm): power_panel = DynamicModelChoiceField( label=_('Power panel'), queryset=PowerPanel.objects.all(), - selector=True + selector=True, + quick_add=True ) rack = DynamicModelChoiceField( label=_('Rack'), diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 56a6dc3d9..53ffe8f3f 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -109,7 +109,8 @@ class RIRForm(NetBoxModelForm): class AggregateForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), - label=_('RIR') + label=_('RIR'), + quick_add=True ) comments = CommentField() @@ -132,6 +133,7 @@ class ASNRangeForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), label=_('RIR'), + quick_add=True ) slug = SlugField() fieldsets = ( @@ -150,6 +152,7 @@ class ASNForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), label=_('RIR'), + quick_add=True ) sites = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -216,7 +219,8 @@ class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm): role = DynamicModelChoiceField( label=_('Role'), queryset=Role.objects.all(), - required=False + required=False, + quick_add=True ) comments = CommentField() @@ -246,7 +250,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm): role = DynamicModelChoiceField( label=_('Role'), queryset=Role.objects.all(), - required=False + required=False, + quick_add=True ) comments = CommentField() @@ -639,7 +644,8 @@ class VLANForm(TenancyForm, NetBoxModelForm): role = DynamicModelChoiceField( label=_('Role'), queryset=Role.objects.all(), - required=False + required=False, + quick_add=True ) qinq_svlan = DynamicModelChoiceField( label=_('Q-in-Q SVLAN'), diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 0686e52b7..fb554ca4f 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -233,18 +233,23 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): form = self.form(instance=obj, initial=initial_data) restrict_form_fields(form, request.user) - # If this is an HTMX request, return only the rendered form HTML - if htmx_partial(request): - return render(request, self.htmx_template_name, { - 'model': model, - 'object': obj, - 'form': form, - }) - - return render(request, self.template_name, { + context = { 'model': model, 'object': obj, 'form': form, + } + + # If the form is being displayed within a "quick add" widget, + # use the appropriate template + if request.GET.get('_quickadd'): + return render(request, 'htmx/quick_add.html', context) + + # If this is an HTMX request, return only the rendered form HTML + if htmx_partial(request): + return render(request, self.htmx_template_name, context) + + return render(request, self.template_name, { + **context, 'return_url': self.get_return_url(request, obj), 'prerequisite_model': get_prerequisite_model(self.queryset), **self.get_extra_context(request, obj), @@ -259,6 +264,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): """ logger = logging.getLogger('netbox.views.ObjectEditView') obj = self.get_object(**kwargs) + model = self.queryset.model # Take a snapshot for change logging (if editing an existing object) if obj.pk and hasattr(obj, 'snapshot'): @@ -292,6 +298,12 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): msg = f'{msg} {obj}' messages.success(request, msg) + # Object was created via "quick add" modal + if '_quickadd' in request.POST: + return render(request, 'htmx/quick_add_created.html', { + 'object': obj, + }) + # If adding another object, redirect back to the edit form if '_addanother' in request.POST: redirect_url = request.path @@ -324,12 +336,19 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): else: logger.debug("Form validation failed") - return render(request, self.template_name, { + context = { + 'model': model, 'object': obj, 'form': form, 'return_url': self.get_return_url(request, obj), **self.get_extra_context(request, obj), - }) + } + + # Form was submitted via a "quick add" widget + if '_quickadd' in request.POST: + return render(request, 'htmx/quick_add.html', context) + + return render(request, self.template_name, context) class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 969d5c73a..5e24ee625 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 0f4ac63d3..a05e8edb5 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/buttons/reslug.ts b/netbox/project-static/src/buttons/reslug.ts index f445854c1..53c21b1f0 100644 --- a/netbox/project-static/src/buttons/reslug.ts +++ b/netbox/project-static/src/buttons/reslug.ts @@ -1,3 +1,5 @@ +import { getElements } from '../util'; + /** * Create a slug from any input string. * @@ -15,34 +17,30 @@ function slugify(slug: string, chars: number): string { } /** - * If a slug field exists, add event listeners to handle automatically generating its value. + * For any slug fields, add event listeners to handle automatically generating slug values. */ export function initReslug(): void { - const slugField = document.getElementById('id_slug') as HTMLInputElement; - const slugButton = document.getElementById('reslug') as HTMLButtonElement; - if (slugField === null || slugButton === null) { - return; - } - const sourceId = slugField.getAttribute('slug-source'); - const sourceField = document.getElementById(`id_${sourceId}`) as HTMLInputElement; + for (const slugButton of getElements('button#reslug')) { + const form = slugButton.form; + if (form == null) continue; + const slugField = form.querySelector('#id_slug') as HTMLInputElement; + if (slugField == null) continue; + const sourceId = slugField.getAttribute('slug-source'); + const sourceField = form.querySelector(`#id_${sourceId}`) as HTMLInputElement; - if (sourceField === null) { - console.error('Unable to find field for slug field.'); - return; - } + const slugLengthAttr = slugField.getAttribute('maxlength'); + let slugLength = 50; - const slugLengthAttr = slugField.getAttribute('maxlength'); - let slugLength = 50; - - if (slugLengthAttr) { - slugLength = Number(slugLengthAttr); - } - sourceField.addEventListener('blur', () => { - if (!slugField.value) { - slugField.value = slugify(sourceField.value, slugLength); + if (slugLengthAttr) { + slugLength = Number(slugLengthAttr); } - }); - slugButton.addEventListener('click', () => { - slugField.value = slugify(sourceField.value, slugLength); - }); + sourceField.addEventListener('blur', () => { + if (!slugField.value) { + slugField.value = slugify(sourceField.value, slugLength); + } + }); + slugButton.addEventListener('click', () => { + slugField.value = slugify(sourceField.value, slugLength); + }); + } } diff --git a/netbox/project-static/src/htmx.ts b/netbox/project-static/src/htmx.ts index f4092036b..6a772011b 100644 --- a/netbox/project-static/src/htmx.ts +++ b/netbox/project-static/src/htmx.ts @@ -4,11 +4,16 @@ import { initSelects } from './select'; import { initObjectSelector } from './objectSelector'; import { initBootstrap } from './bs'; import { initMessages } from './messages'; +import { initQuickAdd } from './quickAdd'; function initDepedencies(): void { - for (const init of [initButtons, initClipboard, initSelects, initObjectSelector, initBootstrap, initMessages]) { - init(); - } + initButtons(); + initClipboard(); + initSelects(); + initObjectSelector(); + initQuickAdd(); + initBootstrap(); + initMessages(); } /** diff --git a/netbox/project-static/src/quickAdd.ts b/netbox/project-static/src/quickAdd.ts new file mode 100644 index 000000000..e038f5d19 --- /dev/null +++ b/netbox/project-static/src/quickAdd.ts @@ -0,0 +1,39 @@ +import { Modal } from 'bootstrap'; + +function handleQuickAddObject(): void { + const quick_add = document.getElementById('quick-add-object'); + if (quick_add == null) return; + + const object_id = quick_add.getAttribute('data-object-id'); + if (object_id == null) return; + const object_repr = quick_add.getAttribute('data-object-repr'); + if (object_repr == null) return; + + const target_id = quick_add.getAttribute('data-target-id'); + if (target_id == null) return; + const target = document.getElementById(target_id); + if (target == null) return; + + //@ts-expect-error tomselect added on init + target.tomselect.addOption({ + id: object_id, + display: object_repr, + }); + //@ts-expect-error tomselect added on init + target.tomselect.addItem(object_id); + + const modal_element = document.getElementById('htmx-modal'); + if (modal_element) { + const modal = Modal.getInstance(modal_element); + if (modal) { + modal.hide(); + } + } +} + +export function initQuickAdd(): void { + const quick_add_modal = document.getElementById('htmx-modal-content'); + if (quick_add_modal) { + quick_add_modal.addEventListener('htmx:afterSwap', () => handleQuickAddObject()); + } +} diff --git a/netbox/templates/htmx/quick_add.html b/netbox/templates/htmx/quick_add.html new file mode 100644 index 000000000..9473e14a1 --- /dev/null +++ b/netbox/templates/htmx/quick_add.html @@ -0,0 +1,28 @@ +{% load form_helpers %} +{% load helpers %} +{% load i18n %} + + + diff --git a/netbox/templates/htmx/quick_add_created.html b/netbox/templates/htmx/quick_add_created.html new file mode 100644 index 000000000..3b1a24c48 --- /dev/null +++ b/netbox/templates/htmx/quick_add_created.html @@ -0,0 +1,22 @@ +{% load form_helpers %} +{% load helpers %} +{% load i18n %} + + + diff --git a/netbox/tenancy/forms/forms.py b/netbox/tenancy/forms/forms.py index 114253e7a..0edb36348 100644 --- a/netbox/tenancy/forms/forms.py +++ b/netbox/tenancy/forms/forms.py @@ -25,6 +25,7 @@ class TenancyForm(forms.Form): label=_('Tenant'), queryset=Tenant.objects.all(), required=False, + quick_add=True, query_params={ 'group_id': '$tenant_group' } diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py index 6666c0e4d..13d5ffc70 100644 --- a/netbox/utilities/forms/fields/dynamic.py +++ b/netbox/utilities/forms/fields/dynamic.py @@ -2,7 +2,7 @@ import django_filters from django import forms from django.conf import settings from django.forms import BoundField -from django.urls import reverse +from django.urls import reverse, reverse_lazy from utilities.forms import widgets from utilities.views import get_viewname @@ -66,6 +66,8 @@ class DynamicModelChoiceMixin: choice (DEPRECATED: pass `context={'disabled': '$fieldname'}` instead) context: A mapping of