mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06:00
* WIP * Misc cleanup * Add warning re: nested quick-adds
This commit is contained in:
parent
9fe6685562
commit
b4f15092db
@ -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()
|
||||
|
||||
|
@ -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'),
|
||||
|
@ -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'),
|
||||
|
@ -233,18 +233,23 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
|
||||
form = self.form(instance=obj, initial=initial_data)
|
||||
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 htmx_partial(request):
|
||||
return render(request, self.htmx_template_name, {
|
||||
'model': model,
|
||||
'object': obj,
|
||||
'form': form,
|
||||
})
|
||||
return render(request, self.htmx_template_name, context)
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'model': model,
|
||||
'object': obj,
|
||||
'form': form,
|
||||
**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):
|
||||
|
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -1,3 +1,5 @@
|
||||
import { getElements } from '../util';
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const slugField = document.getElementById('id_slug') as HTMLInputElement;
|
||||
const slugButton = document.getElementById('reslug') as HTMLButtonElement;
|
||||
if (slugField === null || slugButton === null) {
|
||||
return;
|
||||
}
|
||||
for (const slugButton of getElements<HTMLButtonElement>('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 = document.getElementById(`id_${sourceId}`) as HTMLInputElement;
|
||||
|
||||
if (sourceField === null) {
|
||||
console.error('Unable to find field for slug field.');
|
||||
return;
|
||||
}
|
||||
const sourceField = form.querySelector(`#id_${sourceId}`) as HTMLInputElement;
|
||||
|
||||
const slugLengthAttr = slugField.getAttribute('maxlength');
|
||||
let slugLength = 50;
|
||||
@ -46,3 +43,4 @@ export function initReslug(): void {
|
||||
slugField.value = slugify(sourceField.value, slugLength);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
39
netbox/project-static/src/quickAdd.ts
Normal file
39
netbox/project-static/src/quickAdd.ts
Normal 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());
|
||||
}
|
||||
}
|
28
netbox/templates/htmx/quick_add.html
Normal file
28
netbox/templates/htmx/quick_add.html
Normal 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>
|
22
netbox/templates/htmx/quick_add_created.html
Normal file
22
netbox/templates/htmx/quick_add_created.html
Normal 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>
|
@ -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'
|
||||
}
|
||||
|
@ -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 <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
|
||||
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:
|
||||
value: The name of the attribute which contains the option's value (default: 'id')
|
||||
@ -90,6 +92,7 @@ class DynamicModelChoiceMixin:
|
||||
disabled_indicator=None,
|
||||
context=None,
|
||||
selector=False,
|
||||
quick_add=False,
|
||||
**kwargs
|
||||
):
|
||||
self.model = queryset.model
|
||||
@ -99,6 +102,7 @@ class DynamicModelChoiceMixin:
|
||||
self.disabled_indicator = disabled_indicator
|
||||
self.context = context or {}
|
||||
self.selector = selector
|
||||
self.quick_add = quick_add
|
||||
|
||||
super().__init__(queryset, **kwargs)
|
||||
|
||||
@ -121,6 +125,12 @@ class DynamicModelChoiceMixin:
|
||||
if self.selector:
|
||||
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
|
||||
|
||||
def get_bound_field(self, form, field_name):
|
||||
|
@ -1,7 +1,8 @@
|
||||
{% load i18n %}
|
||||
{% if widget.attrs.selector and not widget.attrs.disabled %}
|
||||
<div class="d-flex">
|
||||
{% include 'django/forms/widgets/select.html' %}
|
||||
{% if widget.attrs.selector and not widget.attrs.disabled %}
|
||||
{# Opens the object selector modal #}
|
||||
<button
|
||||
type="button"
|
||||
title="{% trans "Open selector" %}"
|
||||
@ -13,7 +14,19 @@
|
||||
>
|
||||
<i class="mdi mdi-database-search-outline"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
{% include 'django/forms/widgets/select.html' %}
|
||||
{% endif %}
|
||||
{% if widget.attrs.quick_add and not widget.attrs.disabled %}
|
||||
{# Opens the quick add modal #}
|
||||
<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>
|
||||
|
@ -62,12 +62,14 @@ class ClusterGroupForm(NetBoxModelForm):
|
||||
class ClusterForm(TenancyForm, ScopedForm, NetBoxModelForm):
|
||||
type = DynamicModelChoiceField(
|
||||
label=_('Type'),
|
||||
queryset=ClusterType.objects.all()
|
||||
queryset=ClusterType.objects.all(),
|
||||
quick_add=True
|
||||
)
|
||||
group = DynamicModelChoiceField(
|
||||
label=_('Group'),
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
quick_add=True
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
|
@ -47,7 +47,8 @@ class TunnelForm(TenancyForm, NetBoxModelForm):
|
||||
group = DynamicModelChoiceField(
|
||||
queryset=TunnelGroup.objects.all(),
|
||||
label=_('Tunnel Group'),
|
||||
required=False
|
||||
required=False,
|
||||
quick_add=True
|
||||
)
|
||||
ipsec_profile = DynamicModelChoiceField(
|
||||
queryset=IPSecProfile.objects.all(),
|
||||
@ -313,7 +314,8 @@ class IKEProposalForm(NetBoxModelForm):
|
||||
class IKEPolicyForm(NetBoxModelForm):
|
||||
proposals = DynamicModelMultipleChoiceField(
|
||||
queryset=IKEProposal.objects.all(),
|
||||
label=_('Proposals')
|
||||
label=_('Proposals'),
|
||||
quick_add=True
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
@ -349,7 +351,8 @@ class IPSecProposalForm(NetBoxModelForm):
|
||||
class IPSecPolicyForm(NetBoxModelForm):
|
||||
proposals = DynamicModelMultipleChoiceField(
|
||||
queryset=IPSecProposal.objects.all(),
|
||||
label=_('Proposals')
|
||||
label=_('Proposals'),
|
||||
quick_add=True
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
|
@ -40,7 +40,8 @@ class WirelessLANForm(ScopedForm, TenancyForm, NetBoxModelForm):
|
||||
group = DynamicModelChoiceField(
|
||||
label=_('Group'),
|
||||
queryset=WirelessLANGroup.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
quick_add=True
|
||||
)
|
||||
vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
|
Loading…
Reference in New Issue
Block a user