Closes #5858: Implement a quick-add UI widget for related objects (#18016)

* WIP

* Misc cleanup

* Add warning re: nested quick-adds
This commit is contained in:
Jeremy Stretch 2024-11-18 14:44:57 -05:00 committed by GitHub
parent 9fe6685562
commit b4f15092db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 228 additions and 68 deletions

View File

@ -50,7 +50,9 @@ class ProviderForm(NetBoxModelForm):
class ProviderAccountForm(NetBoxModelForm): class ProviderAccountForm(NetBoxModelForm):
provider = DynamicModelChoiceField( provider = DynamicModelChoiceField(
label=_('Provider'), label=_('Provider'),
queryset=Provider.objects.all() queryset=Provider.objects.all(),
selector=True,
quick_add=True
) )
comments = CommentField() comments = CommentField()
@ -64,7 +66,9 @@ class ProviderAccountForm(NetBoxModelForm):
class ProviderNetworkForm(NetBoxModelForm): class ProviderNetworkForm(NetBoxModelForm):
provider = DynamicModelChoiceField( provider = DynamicModelChoiceField(
label=_('Provider'), label=_('Provider'),
queryset=Provider.objects.all() queryset=Provider.objects.all(),
selector=True,
quick_add=True
) )
comments = CommentField() comments = CommentField()
@ -97,7 +101,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
provider = DynamicModelChoiceField( provider = DynamicModelChoiceField(
label=_('Provider'), label=_('Provider'),
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
selector=True selector=True,
quick_add=True
) )
provider_account = DynamicModelChoiceField( provider_account = DynamicModelChoiceField(
label=_('Provider account'), label=_('Provider account'),
@ -108,7 +113,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
} }
) )
type = DynamicModelChoiceField( type = DynamicModelChoiceField(
queryset=CircuitType.objects.all() queryset=CircuitType.objects.all(),
quick_add=True
) )
comments = CommentField() comments = CommentField()

View File

@ -112,12 +112,14 @@ class SiteForm(TenancyForm, NetBoxModelForm):
region = DynamicModelChoiceField( region = DynamicModelChoiceField(
label=_('Region'), label=_('Region'),
queryset=Region.objects.all(), queryset=Region.objects.all(),
required=False required=False,
quick_add=True
) )
group = DynamicModelChoiceField( group = DynamicModelChoiceField(
label=_('Group'), label=_('Group'),
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
required=False required=False,
quick_add=True
) )
asns = DynamicModelMultipleChoiceField( asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(), queryset=ASN.objects.all(),
@ -206,7 +208,8 @@ class RackRoleForm(NetBoxModelForm):
class RackTypeForm(NetBoxModelForm): class RackTypeForm(NetBoxModelForm):
manufacturer = DynamicModelChoiceField( manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'), label=_('Manufacturer'),
queryset=Manufacturer.objects.all() queryset=Manufacturer.objects.all(),
quick_add=True
) )
comments = CommentField() comments = CommentField()
slug = SlugField( slug = SlugField(
@ -348,7 +351,8 @@ class ManufacturerForm(NetBoxModelForm):
class DeviceTypeForm(NetBoxModelForm): class DeviceTypeForm(NetBoxModelForm):
manufacturer = DynamicModelChoiceField( manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'), label=_('Manufacturer'),
queryset=Manufacturer.objects.all() queryset=Manufacturer.objects.all(),
quick_add=True
) )
default_platform = DynamicModelChoiceField( default_platform = DynamicModelChoiceField(
label=_('Default platform'), label=_('Default platform'),
@ -436,7 +440,8 @@ class PlatformForm(NetBoxModelForm):
manufacturer = DynamicModelChoiceField( manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'), label=_('Manufacturer'),
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False required=False,
quick_add=True
) )
config_template = DynamicModelChoiceField( config_template = DynamicModelChoiceField(
label=_('Config template'), label=_('Config template'),
@ -508,7 +513,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
) )
role = DynamicModelChoiceField( role = DynamicModelChoiceField(
label=_('Device role'), label=_('Device role'),
queryset=DeviceRole.objects.all() queryset=DeviceRole.objects.all(),
quick_add=True
) )
platform = DynamicModelChoiceField( platform = DynamicModelChoiceField(
label=_('Platform'), label=_('Platform'),
@ -750,7 +756,8 @@ class PowerFeedForm(TenancyForm, NetBoxModelForm):
power_panel = DynamicModelChoiceField( power_panel = DynamicModelChoiceField(
label=_('Power panel'), label=_('Power panel'),
queryset=PowerPanel.objects.all(), queryset=PowerPanel.objects.all(),
selector=True selector=True,
quick_add=True
) )
rack = DynamicModelChoiceField( rack = DynamicModelChoiceField(
label=_('Rack'), label=_('Rack'),

View File

@ -109,7 +109,8 @@ class RIRForm(NetBoxModelForm):
class AggregateForm(TenancyForm, NetBoxModelForm): class AggregateForm(TenancyForm, NetBoxModelForm):
rir = DynamicModelChoiceField( rir = DynamicModelChoiceField(
queryset=RIR.objects.all(), queryset=RIR.objects.all(),
label=_('RIR') label=_('RIR'),
quick_add=True
) )
comments = CommentField() comments = CommentField()
@ -132,6 +133,7 @@ class ASNRangeForm(TenancyForm, NetBoxModelForm):
rir = DynamicModelChoiceField( rir = DynamicModelChoiceField(
queryset=RIR.objects.all(), queryset=RIR.objects.all(),
label=_('RIR'), label=_('RIR'),
quick_add=True
) )
slug = SlugField() slug = SlugField()
fieldsets = ( fieldsets = (
@ -150,6 +152,7 @@ class ASNForm(TenancyForm, NetBoxModelForm):
rir = DynamicModelChoiceField( rir = DynamicModelChoiceField(
queryset=RIR.objects.all(), queryset=RIR.objects.all(),
label=_('RIR'), label=_('RIR'),
quick_add=True
) )
sites = DynamicModelMultipleChoiceField( sites = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
@ -216,7 +219,8 @@ class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm):
role = DynamicModelChoiceField( role = DynamicModelChoiceField(
label=_('Role'), label=_('Role'),
queryset=Role.objects.all(), queryset=Role.objects.all(),
required=False required=False,
quick_add=True
) )
comments = CommentField() comments = CommentField()
@ -246,7 +250,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
role = DynamicModelChoiceField( role = DynamicModelChoiceField(
label=_('Role'), label=_('Role'),
queryset=Role.objects.all(), queryset=Role.objects.all(),
required=False required=False,
quick_add=True
) )
comments = CommentField() comments = CommentField()
@ -639,7 +644,8 @@ class VLANForm(TenancyForm, NetBoxModelForm):
role = DynamicModelChoiceField( role = DynamicModelChoiceField(
label=_('Role'), label=_('Role'),
queryset=Role.objects.all(), queryset=Role.objects.all(),
required=False required=False,
quick_add=True
) )
qinq_svlan = DynamicModelChoiceField( qinq_svlan = DynamicModelChoiceField(
label=_('Q-in-Q SVLAN'), label=_('Q-in-Q SVLAN'),

View File

@ -233,18 +233,23 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
form = self.form(instance=obj, initial=initial_data) form = self.form(instance=obj, initial=initial_data)
restrict_form_fields(form, request.user) restrict_form_fields(form, request.user)
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 this is an HTMX request, return only the rendered form HTML
if htmx_partial(request): if htmx_partial(request):
return render(request, self.htmx_template_name, { return render(request, self.htmx_template_name, context)
'model': model,
'object': obj,
'form': form,
})
return render(request, self.template_name, { return render(request, self.template_name, {
'model': model, **context,
'object': obj,
'form': form,
'return_url': self.get_return_url(request, obj), 'return_url': self.get_return_url(request, obj),
'prerequisite_model': get_prerequisite_model(self.queryset), 'prerequisite_model': get_prerequisite_model(self.queryset),
**self.get_extra_context(request, obj), **self.get_extra_context(request, obj),
@ -259,6 +264,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
""" """
logger = logging.getLogger('netbox.views.ObjectEditView') logger = logging.getLogger('netbox.views.ObjectEditView')
obj = self.get_object(**kwargs) obj = self.get_object(**kwargs)
model = self.queryset.model
# Take a snapshot for change logging (if editing an existing object) # Take a snapshot for change logging (if editing an existing object)
if obj.pk and hasattr(obj, 'snapshot'): if obj.pk and hasattr(obj, 'snapshot'):
@ -292,6 +298,12 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
msg = f'{msg} {obj}' msg = f'{msg} {obj}'
messages.success(request, msg) 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 adding another object, redirect back to the edit form
if '_addanother' in request.POST: if '_addanother' in request.POST:
redirect_url = request.path redirect_url = request.path
@ -324,12 +336,19 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
else: else:
logger.debug("Form validation failed") logger.debug("Form validation failed")
return render(request, self.template_name, { context = {
'model': model,
'object': obj, 'object': obj,
'form': form, 'form': form,
'return_url': self.get_return_url(request, obj), 'return_url': self.get_return_url(request, obj),
**self.get_extra_context(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): class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):

Binary file not shown.

Binary file not shown.

View File

@ -1,3 +1,5 @@
import { getElements } from '../util';
/** /**
* Create a slug from any input string. * Create a slug from any input string.
* *
@ -15,21 +17,16 @@ 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 { export function initReslug(): void {
const slugField = document.getElementById('id_slug') as HTMLInputElement; for (const slugButton of getElements<HTMLButtonElement>('button#reslug')) {
const slugButton = document.getElementById('reslug') as HTMLButtonElement; const form = slugButton.form;
if (slugField === null || slugButton === null) { if (form == null) continue;
return; const slugField = form.querySelector('#id_slug') as HTMLInputElement;
} if (slugField == null) continue;
const sourceId = slugField.getAttribute('slug-source'); const sourceId = slugField.getAttribute('slug-source');
const sourceField = document.getElementById(`id_${sourceId}`) as HTMLInputElement; 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'); const slugLengthAttr = slugField.getAttribute('maxlength');
let slugLength = 50; let slugLength = 50;
@ -45,4 +42,5 @@ export function initReslug(): void {
slugButton.addEventListener('click', () => { slugButton.addEventListener('click', () => {
slugField.value = slugify(sourceField.value, slugLength); slugField.value = slugify(sourceField.value, slugLength);
}); });
}
} }

View File

@ -4,11 +4,16 @@ import { initSelects } from './select';
import { initObjectSelector } from './objectSelector'; import { initObjectSelector } from './objectSelector';
import { initBootstrap } from './bs'; import { initBootstrap } from './bs';
import { initMessages } from './messages'; import { initMessages } from './messages';
import { initQuickAdd } from './quickAdd';
function initDepedencies(): void { function initDepedencies(): void {
for (const init of [initButtons, initClipboard, initSelects, initObjectSelector, initBootstrap, initMessages]) { initButtons();
init(); initClipboard();
} initSelects();
initObjectSelector();
initQuickAdd();
initBootstrap();
initMessages();
} }
/** /**

View File

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

View File

@ -0,0 +1,28 @@
{% load form_helpers %}
{% load helpers %}
{% load i18n %}
<div class="modal-header">
<h2 class="modal-title">
{% trans "Quick Add" %} {{ model|meta:"verbose_name"|bettertitle }}
</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body row">
<form
hx-post="{% url model|viewname:"add" %}?_quickadd=True&target={{ request.GET.target }}"
hx-target="#htmx-modal-content"
enctype="multipart/form-data"
>
{% csrf_token %}
{% include 'htmx/form.html' %}
<div class="text-end">
<button type="button" class="btn btn-outline-secondary btn-float" data-bs-dismiss="modal" aria-label="Cancel">
{% trans "Cancel" %}
</button>
<button type="submit" name="_quickadd" class="btn btn-primary">
{% trans "Create" %}
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,22 @@
{% load form_helpers %}
{% load helpers %}
{% load i18n %}
<div class="modal-header">
<h2 class="modal-title">
{{ object|meta:"verbose_name"|bettertitle }} {% trans "Created" %}
</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body row">
{# This content is intended to be scraped and populated in the targeted selection field. #}
<p id="quick-add-object"
data-object-repr="{{ object }}"
data-object-id="{{ object.pk }}"
data-target-id="{{ request.GET.target }}"
>
{% blocktrans with object=object|linkify object_type=object|meta:"verbose_name" %}
Created {{ object_type }} {{ object }}
{% endblocktrans %}
</p>
</div>

View File

@ -25,6 +25,7 @@ class TenancyForm(forms.Form):
label=_('Tenant'), label=_('Tenant'),
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False, required=False,
quick_add=True,
query_params={ query_params={
'group_id': '$tenant_group' 'group_id': '$tenant_group'
} }

View File

@ -2,7 +2,7 @@ import django_filters
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.forms import BoundField from django.forms import BoundField
from django.urls import reverse from django.urls import reverse, reverse_lazy
from utilities.forms import widgets from utilities.forms import widgets
from utilities.views import get_viewname from utilities.views import get_viewname
@ -66,6 +66,8 @@ class DynamicModelChoiceMixin:
choice (DEPRECATED: pass `context={'disabled': '$fieldname'}` instead) choice (DEPRECATED: pass `context={'disabled': '$fieldname'}` instead)
context: A mapping of <option> template variables to their API data keys (optional; see below) context: A mapping of <option> template variables to their API data keys (optional; see below)
selector: Include an advanced object selection widget to assist the user in identifying the desired object selector: Include an advanced object selection widget to assist the user in identifying the desired object
quick_add: Include a widget to quickly create a new related object for assignment. NOTE: Nested usage of
quick-add fields is not currently supported.
Context keys: Context keys:
value: The name of the attribute which contains the option's value (default: 'id') value: The name of the attribute which contains the option's value (default: 'id')
@ -90,6 +92,7 @@ class DynamicModelChoiceMixin:
disabled_indicator=None, disabled_indicator=None,
context=None, context=None,
selector=False, selector=False,
quick_add=False,
**kwargs **kwargs
): ):
self.model = queryset.model self.model = queryset.model
@ -99,6 +102,7 @@ class DynamicModelChoiceMixin:
self.disabled_indicator = disabled_indicator self.disabled_indicator = disabled_indicator
self.context = context or {} self.context = context or {}
self.selector = selector self.selector = selector
self.quick_add = quick_add
super().__init__(queryset, **kwargs) super().__init__(queryset, **kwargs)
@ -121,6 +125,12 @@ class DynamicModelChoiceMixin:
if self.selector: if self.selector:
attrs['selector'] = self.model._meta.label_lower attrs['selector'] = self.model._meta.label_lower
# Include quick add?
if self.quick_add:
app_label = self.model._meta.app_label
model_name = self.model._meta.model_name
attrs['quick_add'] = reverse_lazy(f'{app_label}:{model_name}_add')
return attrs return attrs
def get_bound_field(self, form, field_name): def get_bound_field(self, form, field_name):

View File

@ -1,7 +1,8 @@
{% load i18n %} {% load i18n %}
{% if widget.attrs.selector and not widget.attrs.disabled %} <div class="d-flex">
<div class="d-flex">
{% include 'django/forms/widgets/select.html' %} {% include 'django/forms/widgets/select.html' %}
{% if widget.attrs.selector and not widget.attrs.disabled %}
{# Opens the object selector modal #}
<button <button
type="button" type="button"
title="{% trans "Open selector" %}" title="{% trans "Open selector" %}"
@ -13,7 +14,19 @@
> >
<i class="mdi mdi-database-search-outline"></i> <i class="mdi mdi-database-search-outline"></i>
</button> </button>
</div> {% endif %}
{% else %} {% if widget.attrs.quick_add and not widget.attrs.disabled %}
{% include 'django/forms/widgets/select.html' %} {# Opens the quick add modal #}
{% endif %} <button
type="button"
title="{% trans "Quick add" %}"
class="btn btn-outline-secondary ms-1"
data-bs-toggle="modal"
data-bs-target="#htmx-modal"
hx-get="{{ widget.attrs.quick_add }}?_quickadd=True&target={{ widget.attrs.id }}"
hx-target="#htmx-modal-content"
>
<i class="mdi mdi-plus-circle"></i>
</button>
{% endif %}
</div>

View File

@ -62,12 +62,14 @@ class ClusterGroupForm(NetBoxModelForm):
class ClusterForm(TenancyForm, ScopedForm, NetBoxModelForm): class ClusterForm(TenancyForm, ScopedForm, NetBoxModelForm):
type = DynamicModelChoiceField( type = DynamicModelChoiceField(
label=_('Type'), label=_('Type'),
queryset=ClusterType.objects.all() queryset=ClusterType.objects.all(),
quick_add=True
) )
group = DynamicModelChoiceField( group = DynamicModelChoiceField(
label=_('Group'), label=_('Group'),
queryset=ClusterGroup.objects.all(), queryset=ClusterGroup.objects.all(),
required=False required=False,
quick_add=True
) )
comments = CommentField() comments = CommentField()

View File

@ -47,7 +47,8 @@ class TunnelForm(TenancyForm, NetBoxModelForm):
group = DynamicModelChoiceField( group = DynamicModelChoiceField(
queryset=TunnelGroup.objects.all(), queryset=TunnelGroup.objects.all(),
label=_('Tunnel Group'), label=_('Tunnel Group'),
required=False required=False,
quick_add=True
) )
ipsec_profile = DynamicModelChoiceField( ipsec_profile = DynamicModelChoiceField(
queryset=IPSecProfile.objects.all(), queryset=IPSecProfile.objects.all(),
@ -313,7 +314,8 @@ class IKEProposalForm(NetBoxModelForm):
class IKEPolicyForm(NetBoxModelForm): class IKEPolicyForm(NetBoxModelForm):
proposals = DynamicModelMultipleChoiceField( proposals = DynamicModelMultipleChoiceField(
queryset=IKEProposal.objects.all(), queryset=IKEProposal.objects.all(),
label=_('Proposals') label=_('Proposals'),
quick_add=True
) )
fieldsets = ( fieldsets = (
@ -349,7 +351,8 @@ class IPSecProposalForm(NetBoxModelForm):
class IPSecPolicyForm(NetBoxModelForm): class IPSecPolicyForm(NetBoxModelForm):
proposals = DynamicModelMultipleChoiceField( proposals = DynamicModelMultipleChoiceField(
queryset=IPSecProposal.objects.all(), queryset=IPSecProposal.objects.all(),
label=_('Proposals') label=_('Proposals'),
quick_add=True
) )
fieldsets = ( fieldsets = (

View File

@ -40,7 +40,8 @@ class WirelessLANForm(ScopedForm, TenancyForm, NetBoxModelForm):
group = DynamicModelChoiceField( group = DynamicModelChoiceField(
label=_('Group'), label=_('Group'),
queryset=WirelessLANGroup.objects.all(), queryset=WirelessLANGroup.objects.all(),
required=False required=False,
quick_add=True
) )
vlan = DynamicModelChoiceField( vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),