diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 9e2d6b383..7572150d4 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 0ba7a62d1..b794d3ce5 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/moveOptions.ts b/netbox/project-static/src/buttons/moveOptions.ts index fee36d609..a98bf732a 100644 --- a/netbox/project-static/src/buttons/moveOptions.ts +++ b/netbox/project-static/src/buttons/moveOptions.ts @@ -1,5 +1,20 @@ import { getElements } from '../util'; +/** + * Move selected options from one select element to another. + * + * @param source Select Element + * @param target Select Element + */ +function moveOption(source: HTMLSelectElement, target: HTMLSelectElement): void { + for (const option of Array.from(source.options)) { + if (option.selected) { + target.appendChild(option.cloneNode(true)); + option.remove(); + } + } +} + /** * Move selected options of a select element up in order. * @@ -39,23 +54,35 @@ function moveOptionDown(element: HTMLSelectElement): void { } /** - * Initialize move up/down buttons. + * Initialize select/move buttons. */ export function initMoveButtons(): void { - for (const button of getElements('#move-option-up')) { + // Move selected option(s) between lists + for (const button of getElements('.move-option')) { + const source = button.getAttribute('data-source'); const target = button.getAttribute('data-target'); - if (target !== null) { - for (const select of getElements(`#${target}`)) { - button.addEventListener('click', () => moveOptionUp(select)); - } + const source_select = document.getElementById(`id_${source}`) as HTMLSelectElement; + const target_select = document.getElementById(`id_${target}`) as HTMLSelectElement; + if (source_select !== null && target_select !== null) { + button.addEventListener('click', () => moveOption(source_select, target_select)); } } - for (const button of getElements('#move-option-down')) { + + // Move selected option(s) up in current list + for (const button of getElements('.move-option-up')) { const target = button.getAttribute('data-target'); - if (target !== null) { - for (const select of getElements(`#${target}`)) { - button.addEventListener('click', () => moveOptionDown(select)); - } + const target_select = document.getElementById(`id_${target}`) as HTMLSelectElement; + if (target_select !== null) { + button.addEventListener('click', () => moveOptionUp(target_select)); + } + } + + // Move selected option(s) down in current list + for (const button of getElements('.move-option-down')) { + const target = button.getAttribute('data-target'); + const target_select = document.getElementById(`id_${target}`) as HTMLSelectElement; + if (target_select !== null) { + button.addEventListener('click', () => moveOptionDown(target_select)); } } } diff --git a/netbox/templates/extras/tableconfig_edit.html b/netbox/templates/extras/tableconfig_edit.html index df1e67082..31057c298 100644 --- a/netbox/templates/extras/tableconfig_edit.html +++ b/netbox/templates/extras/tableconfig_edit.html @@ -36,10 +36,10 @@
{{ form.columns }} - + {% trans "Move Up" %} - + {% trans "Move Down" %}
diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index d8773feb4..d875b0792 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -15,7 +15,7 @@ from users.models import * from utilities.data import flatten_dict from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet -from utilities.forms.widgets import DateTimePicker +from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget from utilities.permissions import qs_filter_from_constraints __all__ = ( @@ -272,12 +272,21 @@ class GroupForm(forms.ModelForm): return instance +def get_object_types_choices(): + return [ + (ot.pk, str(ot)) + for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model') + ] + + class ObjectPermissionForm(forms.ModelForm): object_types = ContentTypeMultipleChoiceField( label=_('Object types'), queryset=ObjectType.objects.all(), - limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES, - widget=forms.SelectMultiple(attrs={'size': 6}) + widget=SplitMultiSelectWidget( + choices=get_object_types_choices + ), + help_text=_('Select the types of objects to which the permission will appy.') ) can_view = forms.BooleanField( required=False diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py index 8226a8be9..e66c00d0a 100644 --- a/netbox/users/tests/test_views.py +++ b/netbox/users/tests/test_views.py @@ -180,7 +180,7 @@ class ObjectPermissionTestCase( cls.form_data = { 'name': 'Permission X', 'description': 'A new permission', - 'object_types': [object_type.pk], + 'object_types_1': [object_type.pk], # SplitMultiSelectWidget requires _1 suffix on field name 'actions': 'view,edit,delete', } diff --git a/netbox/utilities/forms/widgets/select.py b/netbox/utilities/forms/widgets/select.py index 8115e2449..7f4e9c87f 100644 --- a/netbox/utilities/forms/widgets/select.py +++ b/netbox/utilities/forms/widgets/select.py @@ -8,6 +8,7 @@ __all__ = ( 'ColorSelect', 'HTMXSelect', 'SelectWithPK', + 'SplitMultiSelectWidget', ) @@ -63,3 +64,79 @@ class SelectWithPK(forms.Select): Include the primary key of each option in the option label (e.g. "Router7 (4721)"). """ option_template_name = 'widgets/select_option_with_pk.html' + + +class AvailableOptions(forms.SelectMultiple): + """ + Renders a including only choices that have _not_ been selected. (For unbound fields, this + will include _all_ choices.) Employed by SplitMultiSelectWidget. + """ + def optgroups(self, name, value, attrs=None): + self.choices = [ + choice for choice in self.choices if str(choice[0]) in value + ] + value = [] # Clear selected choices + return super().optgroups(name, value, attrs) + + +class SplitMultiSelectWidget(forms.MultiWidget): + """ + Renders two